diff --git a/.ci/azure-pipelines-package.yml b/.ci/azure-pipelines-package.yml index 1618237f1a..c28b1bf7f0 100644 --- a/.ci/azure-pipelines-package.yml +++ b/.ci/azure-pipelines-package.yml @@ -47,7 +47,7 @@ jobs: displayName: Set release version (stable) condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v') - - script: 'docker build -f deployment/Dockerfile.$(BuildConfiguration) -t jellyfin-server-$(BuildConfiguration) deployment' + - script: 'docker build -f deployment/Dockerfile.$(BuildConfiguration) -t jellyfin-server-$(BuildConfiguration) --label "org.opencontainers.image.url=$(Build.Repository.Uri)" --label "org.opencontainers.image.revision=$(Build.SourceVersion)" deployment' displayName: 'Build Dockerfile' - script: 'docker image ls -a && docker run -v $(pwd)/deployment/dist:/dist -v $(pwd):/jellyfin -e IS_UNSTABLE="yes" -e BUILD_ID=$(Build.BuildNumber) jellyfin-server-$(BuildConfiguration)' diff --git a/.github/ISSUE_TEMPLATE/issue report.yml b/.github/ISSUE_TEMPLATE/issue report.yml index fd377df9db..5878028330 100644 --- a/.github/ISSUE_TEMPLATE/issue report.yml +++ b/.github/ISSUE_TEMPLATE/issue report.yml @@ -30,9 +30,9 @@ body: label: Jellyfin Version description: What version of Jellyfin are you running? options: - - 10.8.0 + - 10.8.z + - 10.8.9 - 10.7.7 - - 10.7.z - 10.6.4 - Other validations: @@ -47,13 +47,15 @@ body: label: Environment description: | Examples: - - **OS**: [e.g. Debian, Windows] + - **OS**: [e.g. Debian 11, Windows 10] + - **Linux Kernel**: [e.g. none, 5.15, 6.1, etc.] - **Virtualization**: [e.g. Docker, KVM, LXC] - **Clients**: [Browser, Android, Fire Stick, etc.] - **Browser**: [e.g. Firefox 91, Chrome 93, Safari 13] - - **FFmpeg Version**: [e.g. 4.3.2-Jellyfin] + - **FFmpeg Version**: [e.g. 5.1.2-Jellyfin] - **Playback**: [Direct Play, Remux, Direct Stream, Transcode] - **Hardware Acceleration**: [e.g. none, VAAPI, NVENC, etc.] + - **GPU Model**: [e.g. none, UHD630, GTX1050, etc.] - **Installed Plugins**: [e.g. none, Fanart, Anime, etc.] - **Reverse Proxy**: [e.g. none, nginx, apache, etc.] - **Base URL**: [e.g. none, yes: /example] @@ -61,12 +63,14 @@ body: - **Storage**: [e.g. local, NFS, cloud] value: | - OS: + - Linux Kernel: - Virtualization: - Clients: - Browser: - FFmpeg Version: - Playback Method: - Hardware Acceleration: + - GPU Model: - Plugins: - Reverse Proxy: - Base URL: @@ -84,8 +88,8 @@ body: id: ffmpeg-logs attributes: label: FFmpeg logs - description: Please copy and paste any relevant log output. This can be found in Dashboard > Logs. - placeholder: It's important to include the specific codec details. If no FFmpeg logs appear, the file was Direct Played and did not use FFmpeg. + description: Please copy and paste recent FFmpeg log output. This can be found in Dashboard > Logs > FFmpeg*.log. + placeholder: This field is mandatory for debugging hardware transcoding issues. It's important to include the specific codec details. If no FFmpeg logs appear, the file was Direct Played and did not use FFmpeg. render: shell - type: textarea id: browserlogs diff --git a/.github/workflows/automation.yml b/.github/workflows/automation.yml index 4b5571c774..47abce02a3 100644 --- a/.github/workflows/automation.yml +++ b/.github/workflows/automation.yml @@ -19,6 +19,7 @@ jobs: if: ${{ github.event_name == 'push' || github.event_name == 'pull_request_target'}} with: dirtyLabel: 'merge conflict' + commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.' repoToken: ${{ secrets.JF_BOT_TOKEN }} project: diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 5779ac3cf9..f83b38949c 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -20,18 +20,18 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Setup .NET - uses: actions/setup-dotnet@607fce577a46308457984d59e4954e075820f10a # tag=v3 + uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0 with: dotnet-version: '7.0.x' - name: Initialize CodeQL - uses: github/codeql-action/init@3ebbd71c74ef574dbc558c82f70e52732c8b44fe # v2 + uses: github/codeql-action/init@f6e388ebf0efc915c6c5b165b019ee61a6746a38 # v2.20.1 with: languages: ${{ matrix.language }} queries: +security-extended - name: Autobuild - uses: github/codeql-action/autobuild@3ebbd71c74ef574dbc558c82f70e52732c8b44fe # v2 + uses: github/codeql-action/autobuild@f6e388ebf0efc915c6c5b165b019ee61a6746a38 # v2.20.1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@3ebbd71c74ef574dbc558c82f70e52732c8b44fe # v2 + uses: github/codeql-action/analyze@f6e388ebf0efc915c6c5b165b019ee61a6746a38 # v2.20.1 diff --git a/.github/workflows/commands.yml b/.github/workflows/commands.yml index 5d945c001b..178959afc9 100644 --- a/.github/workflows/commands.yml +++ b/.github/workflows/commands.yml @@ -17,14 +17,14 @@ jobs: runs-on: ubuntu-latest steps: - name: Notify as seen - uses: peter-evans/create-or-update-comment@5adcb0bb0f9fb3f95ef05400558bdb3f329ee808 # tag=v2 + uses: peter-evans/create-or-update-comment@c6c9a1a66007646a28c153e2a8580a5bad27bcfa # v3.0.2 with: token: ${{ secrets.JF_BOT_TOKEN }} comment-id: ${{ github.event.comment.id }} reactions: '+1' - name: Checkout the latest code - uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 with: token: ${{ secrets.JF_BOT_TOKEN }} fetch-depth: 0 @@ -43,7 +43,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Notify as seen - uses: peter-evans/create-or-update-comment@5adcb0bb0f9fb3f95ef05400558bdb3f329ee808 # tag=v2 + uses: peter-evans/create-or-update-comment@c6c9a1a66007646a28c153e2a8580a5bad27bcfa # v3.0.2 if: ${{ github.event.comment != null }} with: token: ${{ secrets.JF_BOT_TOKEN }} @@ -51,14 +51,14 @@ jobs: reactions: eyes - name: Checkout the latest code - uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 with: token: ${{ secrets.JF_BOT_TOKEN }} fetch-depth: 0 - name: Notify as running id: comment_running - uses: peter-evans/create-or-update-comment@5adcb0bb0f9fb3f95ef05400558bdb3f329ee808 # tag=v2 + uses: peter-evans/create-or-update-comment@c6c9a1a66007646a28c153e2a8580a5bad27bcfa # v3.0.2 if: ${{ github.event.comment != null }} with: token: ${{ secrets.JF_BOT_TOKEN }} @@ -93,7 +93,7 @@ jobs: exit ${retcode} - name: Notify with result success - uses: peter-evans/create-or-update-comment@5adcb0bb0f9fb3f95ef05400558bdb3f329ee808 # tag=v2 + uses: peter-evans/create-or-update-comment@c6c9a1a66007646a28c153e2a8580a5bad27bcfa # v3.0.2 if: ${{ github.event.comment != null && success() }} with: token: ${{ secrets.JF_BOT_TOKEN }} @@ -108,7 +108,7 @@ jobs: reactions: hooray - name: Notify with result failure - uses: peter-evans/create-or-update-comment@5adcb0bb0f9fb3f95ef05400558bdb3f329ee808 # tag=v2 + uses: peter-evans/create-or-update-comment@c6c9a1a66007646a28c153e2a8580a5bad27bcfa # v3.0.2 if: ${{ github.event.comment != null && failure() }} with: token: ${{ secrets.JF_BOT_TOKEN }} diff --git a/.github/workflows/openapi.yml b/.github/workflows/openapi.yml index 4577ff5251..d3dfd0a6aa 100644 --- a/.github/workflows/openapi.yml +++ b/.github/workflows/openapi.yml @@ -14,18 +14,18 @@ jobs: permissions: read-all steps: - name: Checkout repository - uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 with: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }} - name: Setup .NET - uses: actions/setup-dotnet@607fce577a46308457984d59e4954e075820f10a # tag=v3 + uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0 with: dotnet-version: '7.0.x' - name: Generate openapi.json run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests" - name: Upload openapi.json - uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3 + uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2 with: name: openapi-head retention-days: 14 @@ -39,25 +39,27 @@ jobs: permissions: read-all steps: - name: Checkout repository - uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 with: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }} fetch-depth: 0 - name: Checkout common ancestor + env: + HEAD_REF: ${{ github.head_ref }} run: | git remote add upstream https://github.com/${{ github.event.pull_request.base.repo.full_name }} git -c protocol.version=2 fetch --prune --progress --no-recurse-submodules upstream +refs/heads/*:refs/remotes/upstream/* +refs/tags/*:refs/tags/* - ANCESTOR_REF=$(git merge-base upstream/${{ github.base_ref }} origin/${{ github.head_ref }}) + ANCESTOR_REF=$(git merge-base upstream/${{ github.base_ref }} origin/$HEAD_REF) git checkout --progress --force $ANCESTOR_REF - name: Setup .NET - uses: actions/setup-dotnet@607fce577a46308457984d59e4954e075820f10a # tag=v3 + uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0 with: dotnet-version: '7.0.x' - name: Generate openapi.json run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests" - name: Upload openapi.json - uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3 + uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2 with: name: openapi-base retention-days: 14 @@ -76,12 +78,12 @@ jobs: - openapi-base steps: - name: Download openapi-head - uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3 + uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2 with: name: openapi-head path: openapi-head - name: Download openapi-base - uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3 + uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2 with: name: openapi-base path: openapi-base @@ -103,14 +105,14 @@ jobs: body="${body//$'\r'/'%0D'}" echo ::set-output name=body::$body - name: Find difference comment - uses: peter-evans/find-comment@81e2da3af01c92f83cb927cf3ace0e085617c556 # v2 + uses: peter-evans/find-comment@a54c31d7fa095754bfef525c0c8e5e5674c4b4b1 # v2.4.0 id: find-comment with: issue-number: ${{ github.event.pull_request.number }} direction: last body-includes: openapi-diff-workflow-comment - name: Reply or edit difference comment (changed) - uses: peter-evans/create-or-update-comment@5adcb0bb0f9fb3f95ef05400558bdb3f329ee808 # tag=v2 + uses: peter-evans/create-or-update-comment@c6c9a1a66007646a28c153e2a8580a5bad27bcfa # v3.0.2 if: ${{ steps.read-diff.outputs.body != '' }} with: issue-number: ${{ github.event.pull_request.number }} @@ -125,7 +127,7 @@ jobs: - name: Edit difference comment (unchanged) - uses: peter-evans/create-or-update-comment@5adcb0bb0f9fb3f95ef05400558bdb3f329ee808 # tag=v2 + uses: peter-evans/create-or-update-comment@c6c9a1a66007646a28c153e2a8580a5bad27bcfa # v3.0.2 if: ${{ steps.read-diff.outputs.body == '' && steps.find-comment.outputs.comment-id != '' }} with: issue-number: ${{ github.event.pull_request.number }} diff --git a/.github/workflows/repo-stale.yaml b/.github/workflows/repo-stale.yaml index 7f6fcffed5..c753c1600a 100644 --- a/.github/workflows/repo-stale.yaml +++ b/.github/workflows/repo-stale.yaml @@ -1,4 +1,4 @@ -name: Issue Stale Check +name: Stale Check on: schedule: @@ -7,12 +7,15 @@ on: permissions: issues: write + pull-requests: write + jobs: - stale: + issues: + name: Check issues runs-on: ubuntu-latest if: ${{ contains(github.repository, 'jellyfin/') }} steps: - - uses: actions/stale@6f05e4244c9a0b2ed3401882b05d701dd0a7289b # v7 + - uses: actions/stale@1160a2240286f5da8ec72b1c0816ce2481aabf84 # v8.0.0 with: repo-token: ${{ secrets.JF_BOT_TOKEN }} days-before-stale: 120 @@ -28,3 +31,21 @@ jobs: If you're the original submitter of this issue, please comment confirming if this issue still affects you in the latest release or master branch, or close the issue if it has been fixed. If you're another user also affected by this bug, please comment confirming so. Either action will remove the stale label. This bot exists to prevent issues from becoming stale and forgotten. Jellyfin is always moving forward, and bugs are often fixed as side effects of other changes. We therefore ask that bug report authors remain vigilant about their issues to ensure they are closed if fixed, or re-confirmed - perhaps with fresh logs or reproduction examples - regularly. If you have any questions you can reach us on [Matrix or Social Media](https://docs.jellyfin.org/general/getting-help.html). + + prs-conflicts: + name: Check PRs with merge conflicts + runs-on: ubuntu-latest + if: ${{ contains(github.repository, 'jellyfin/') }} + steps: + - uses: actions/stale@1160a2240286f5da8ec72b1c0816ce2481aabf84 # v8.0.0 + with: + repo-token: ${{ secrets.JF_BOT_TOKEN }} + operations-per-run: 75 + # The merge conflict action will remove the label when updated + remove-stale-when-updated: false + days-before-stale: -1 + days-before-close: 90 + days-before-issue-close: -1 + stale-pr-label: merge conflict + close-pr-message: |- + This PR has been closed due to having unresolved merge conflicts. diff --git a/.npmrc b/.npmrc deleted file mode 100644 index b7a317000b..0000000000 --- a/.npmrc +++ /dev/null @@ -1,3 +0,0 @@ -registry=https://registry.npmjs.org/ -@jellyfin:registry=https://pkgs.dev.azure.com/jellyfin-project/jellyfin/_packaging/unstable/npm/registry/ -always-auth=true \ No newline at end of file diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index ec3c6fd2af..dfb61df0a1 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -58,6 +58,7 @@ - [HelloWorld017](https://github.com/HelloWorld017) - [ikomhoog](https://github.com/ikomhoog) - [jftuga](https://github.com/jftuga) + - [jmshrv](https://github.com/jmshrv) - [joern-h](https://github.com/joern-h) - [joshuaboniface](https://github.com/joshuaboniface) - [JustAMan](https://github.com/JustAMan) @@ -125,6 +126,7 @@ - [SuperSandro2000](https://github.com/SuperSandro2000) - [tbraeutigam](https://github.com/tbraeutigam) - [teacupx](https://github.com/teacupx) + - [TelepathicWalrus](https://github.com/TelepathicWalrus) - [Terror-Gene](https://github.com/Terror-Gene) - [ThatNerdyPikachu](https://github.com/ThatNerdyPikachu) - [ThibaultNocchi](https://github.com/ThibaultNocchi) @@ -162,6 +164,8 @@ - [vgambier](https://github.com/vgambier) - [MinecraftPlaye](https://github.com/MinecraftPlaye) - [RealGreenDragon](https://github.com/RealGreenDragon) + - [ipitio](https://github.com/ipitio) + - [TheTyrius](https://github.com/TheTyrius) # Emby Contributors @@ -231,3 +235,4 @@ - [Matthew Jones](https://github.com/matthew-jones-uk) - [Jakob Kukla](https://github.com/jakobkukla) - [Utku Özdemir](https://github.com/utkuozdemir) + - [JPUC1143](https://github.com/Jpuc1143/) diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000000..c3532467af --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,92 @@ + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Dockerfile b/Dockerfile index 304f794631..e51d285e12 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,7 @@ RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine- && curl -L https://github.com/jellyfin/jellyfin-web/archive/${JELLYFIN_WEB_VERSION}.tar.gz | tar zxf - \ && cd jellyfin-web-* \ && npm ci --no-audit --unsafe-perm \ + && npm run build:production \ && mv dist /dist FROM debian:stable-slim as app @@ -37,7 +38,7 @@ RUN apt-get update \ && apt-get update \ && apt-get install --no-install-recommends --no-install-suggests -y \ mesa-va-drivers \ - jellyfin-ffmpeg \ + jellyfin-ffmpeg5 \ openssl \ locales \ # Intel VAAPI Tone mapping dependencies: diff --git a/Dockerfile.arm b/Dockerfile.arm index bbb84a461c..46a3e9b998 100644 --- a/Dockerfile.arm +++ b/Dockerfile.arm @@ -11,6 +11,7 @@ RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine- && curl -L https://github.com/jellyfin/jellyfin-web/archive/${JELLYFIN_WEB_VERSION}.tar.gz | tar zxf - \ && cd jellyfin-web-* \ && npm ci --no-audit --unsafe-perm \ + && npm run build:production \ && mv dist /dist FROM multiarch/qemu-user-static:x86_64-arm as qemu diff --git a/Dockerfile.arm64 b/Dockerfile.arm64 index 5572586ae9..4f9d5e1fdc 100644 --- a/Dockerfile.arm64 +++ b/Dockerfile.arm64 @@ -11,6 +11,7 @@ RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine- && curl -L https://github.com/jellyfin/jellyfin-web/archive/${JELLYFIN_WEB_VERSION}.tar.gz | tar zxf - \ && cd jellyfin-web-* \ && npm ci --no-audit --unsafe-perm \ + && npm run build:production \ && mv dist /dist FROM multiarch/qemu-user-static:x86_64-aarch64 as qemu diff --git a/Emby.Dlna/ConnectionManager/ConnectionManagerXmlBuilder.cs b/Emby.Dlna/ConnectionManager/ConnectionManagerXmlBuilder.cs index c484dac542..db1190ae7c 100644 --- a/Emby.Dlna/ConnectionManager/ConnectionManagerXmlBuilder.cs +++ b/Emby.Dlna/ConnectionManager/ConnectionManagerXmlBuilder.cs @@ -27,7 +27,7 @@ namespace Emby.Dlna.ConnectionManager /// The . private static IEnumerable GetStateVariables() { - var list = new List + return new StateVariable[] { new StateVariable { @@ -114,8 +114,6 @@ namespace Emby.Dlna.ConnectionManager SendsEvents = false } }; - - return list; } } } diff --git a/Emby.Dlna/ContentDirectory/ContentDirectoryXmlBuilder.cs b/Emby.Dlna/ContentDirectory/ContentDirectoryXmlBuilder.cs index 3edaabb70e..9af28aa7cb 100644 --- a/Emby.Dlna/ContentDirectory/ContentDirectoryXmlBuilder.cs +++ b/Emby.Dlna/ContentDirectory/ContentDirectoryXmlBuilder.cs @@ -27,7 +27,7 @@ namespace Emby.Dlna.ContentDirectory /// The . private static IEnumerable GetStateVariables() { - var list = new List + return new StateVariable[] { new StateVariable { @@ -154,8 +154,6 @@ namespace Emby.Dlna.ContentDirectory SendsEvents = false } }; - - return list; } } } diff --git a/Emby.Dlna/Didl/DidlBuilder.cs b/Emby.Dlna/Didl/DidlBuilder.cs index bea7a5a0da..f668dc829a 100644 --- a/Emby.Dlna/Didl/DidlBuilder.cs +++ b/Emby.Dlna/Didl/DidlBuilder.cs @@ -10,6 +10,7 @@ using System.Text; using System.Xml; using Emby.Dlna.ContentDirectory; using Jellyfin.Data.Entities; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Entities; @@ -870,11 +871,11 @@ namespace Emby.Dlna.Didl var types = new[] { - PersonType.Director, - PersonType.Writer, - PersonType.Producer, - PersonType.Composer, - "creator" + PersonKind.Director, + PersonKind.Writer, + PersonKind.Producer, + PersonKind.Composer, + PersonKind.Creator }; // Seeing some LG models locking up due content with large lists of people @@ -888,10 +889,13 @@ namespace Emby.Dlna.Didl foreach (var actor in people) { - var type = types.FirstOrDefault(i => string.Equals(i, actor.Type, StringComparison.OrdinalIgnoreCase) || string.Equals(i, actor.Role, StringComparison.OrdinalIgnoreCase)) - ?? PersonType.Actor; + var type = types.FirstOrDefault(i => i == actor.Type || string.Equals(actor.Role, i.ToString(), StringComparison.OrdinalIgnoreCase)); + if (type == PersonKind.Unknown) + { + type = PersonKind.Actor; + } - AddValue(writer, "upnp", type.ToLowerInvariant(), actor.Name, NsUpnp); + AddValue(writer, "upnp", type.ToString().ToLowerInvariant(), actor.Name, NsUpnp); } } diff --git a/Emby.Dlna/Emby.Dlna.csproj b/Emby.Dlna/Emby.Dlna.csproj index 60e6dd644d..aca2399644 100644 --- a/Emby.Dlna/Emby.Dlna.csproj +++ b/Emby.Dlna/Emby.Dlna.csproj @@ -28,13 +28,13 @@ - + all runtime; build; native; contentfiles; analyzers - - - + + + @@ -80,7 +80,7 @@ - + diff --git a/Emby.Dlna/Eventing/DlnaEventManager.cs b/Emby.Dlna/Eventing/DlnaEventManager.cs index c0eacf5d83..ecbbdf9df9 100644 --- a/Emby.Dlna/Eventing/DlnaEventManager.cs +++ b/Emby.Dlna/Eventing/DlnaEventManager.cs @@ -164,7 +164,7 @@ namespace Emby.Dlna.Eventing try { - using var response = await _httpClientFactory.CreateClient(NamedClient.Default) + using var response = await _httpClientFactory.CreateClient(NamedClient.DirectIp) .SendAsync(options, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false); } catch (OperationCanceledException) diff --git a/Emby.Dlna/Main/DlnaEntryPoint.cs b/Emby.Dlna/Main/DlnaEntryPoint.cs index aab475153b..39cfc2d1d4 100644 --- a/Emby.Dlna/Main/DlnaEntryPoint.cs +++ b/Emby.Dlna/Main/DlnaEntryPoint.cs @@ -7,7 +7,6 @@ using System.Globalization; using System.Linq; using System.Net.Http; using System.Net.Sockets; -using System.Runtime.InteropServices; using System.Threading.Tasks; using Emby.Dlna.PlayTo; using Emby.Dlna.Ssdp; diff --git a/Emby.Dlna/PlayTo/DlnaHttpClient.cs b/Emby.Dlna/PlayTo/DlnaHttpClient.cs index 75ff542dd8..8b983e9e3d 100644 --- a/Emby.Dlna/PlayTo/DlnaHttpClient.cs +++ b/Emby.Dlna/PlayTo/DlnaHttpClient.cs @@ -2,9 +2,11 @@ using System; using System.Globalization; +using System.IO; using System.Net.Http; using System.Net.Mime; using System.Text; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using System.Xml; @@ -15,7 +17,10 @@ using Microsoft.Extensions.Logging; namespace Emby.Dlna.PlayTo { - public class DlnaHttpClient + /// + /// Http client for Dlna PlayTo function. + /// + public partial class DlnaHttpClient { private readonly ILogger _logger; private readonly IHttpClientFactory _httpClientFactory; @@ -44,25 +49,44 @@ namespace Emby.Dlna.PlayTo private async Task SendRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken) { - using var response = await _httpClientFactory.CreateClient(NamedClient.Dlna).SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var client = _httpClientFactory.CreateClient(NamedClient.Dlna); + using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using MemoryStream ms = new MemoryStream(); + await response.Content.CopyToAsync(ms, cancellationToken).ConfigureAwait(false); try { return await XDocument.LoadAsync( - stream, + ms, LoadOptions.None, cancellationToken).ConfigureAwait(false); } - catch (XmlException ex) + catch (XmlException) { - _logger.LogError(ex, "Failed to parse response"); - if (_logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("Malformed response: {Content}\n", await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false)); - } + // try correcting the Xml response with common errors + ms.Position = 0; + using StreamReader sr = new StreamReader(ms); + var xmlString = await sr.ReadToEndAsync(cancellationToken).ConfigureAwait(false); - return null; + // find and replace unescaped ampersands (&) + xmlString = EscapeAmpersandRegex().Replace(xmlString, "&"); + + try + { + // retry reading Xml + using var xmlReader = new StringReader(xmlString); + return await XDocument.LoadAsync( + xmlReader, + LoadOptions.None, + cancellationToken).ConfigureAwait(false); + } + catch (XmlException ex) + { + _logger.LogError(ex, "Failed to parse response"); + _logger.LogDebug("Malformed response: {Content}\n", xmlString); + + return null; + } } } @@ -104,5 +128,12 @@ namespace Emby.Dlna.PlayTo // Have to await here instead of returning the Task directly, otherwise request would be disposed too soon return await SendRequestAsync(request, cancellationToken).ConfigureAwait(false); } + + /// + /// Compile-time generated regular expression for escaping ampersands. + /// + /// Compiled regular expression. + [GeneratedRegex("(&(?![a-z]*;))")] + private static partial Regex EscapeAmpersandRegex(); } } diff --git a/Emby.Dlna/PlayTo/PlayToController.cs b/Emby.Dlna/PlayTo/PlayToController.cs index 7b1f942c5a..86db363374 100644 --- a/Emby.Dlna/PlayTo/PlayToController.cs +++ b/Emby.Dlna/PlayTo/PlayToController.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System; @@ -66,7 +64,8 @@ namespace Emby.Dlna.PlayTo IUserDataManager userDataManager, ILocalizationManager localization, IMediaSourceManager mediaSourceManager, - IMediaEncoder mediaEncoder) + IMediaEncoder mediaEncoder, + Device device) { _session = session; _sessionManager = sessionManager; @@ -82,14 +81,7 @@ namespace Emby.Dlna.PlayTo _localization = localization; _mediaSourceManager = mediaSourceManager; _mediaEncoder = mediaEncoder; - } - public bool IsSessionActive => !_disposed && _device is not null; - - public bool SupportsMediaControl => IsSessionActive; - - public void Init(Device device) - { _device = device; _device.OnDeviceUnavailable = OnDeviceUnavailable; _device.PlaybackStart += OnDevicePlaybackStart; @@ -102,6 +94,10 @@ namespace Emby.Dlna.PlayTo _deviceDiscovery.DeviceLeft += OnDeviceDiscoveryDeviceLeft; } + public bool IsSessionActive => !_disposed; + + public bool SupportsMediaControl => IsSessionActive; + /* * Send a message to the DLNA device to notify what is the next track in the playlist. */ @@ -131,22 +127,22 @@ namespace Emby.Dlna.PlayTo } } - private void OnDeviceDiscoveryDeviceLeft(object sender, GenericEventArgs e) + private void OnDeviceDiscoveryDeviceLeft(object? sender, GenericEventArgs 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 name switch { - return Task.CompletedTask; - } - - if (name == SessionMessageType.Play) - { - return SendPlayCommand(data as PlayRequest, cancellationToken); - } - - if (name == SessionMessageType.Playstate) - { - 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 GetMediaSource(CancellationToken cancellationToken) + public async Task GetMediaSource(CancellationToken cancellationToken) { if (_mediaSource is not null) { @@ -944,8 +924,8 @@ namespace Emby.Dlna.PlayTo { var part = parts[i]; - if (string.Equals(part, "audio", StringComparison.OrdinalIgnoreCase) || - string.Equals(part, "videos", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(part, "audio", StringComparison.OrdinalIgnoreCase) + || string.Equals(part, "videos", StringComparison.OrdinalIgnoreCase)) { if (Guid.TryParse(parts[i + 1], out var result)) { diff --git a/Emby.Dlna/PlayTo/PlayToManager.cs b/Emby.Dlna/PlayTo/PlayToManager.cs index f4a9a90af4..b469c9cb06 100644 --- a/Emby.Dlna/PlayTo/PlayToManager.cs +++ b/Emby.Dlna/PlayTo/PlayToManager.cs @@ -205,12 +205,11 @@ namespace Emby.Dlna.PlayTo _userDataManager, _localization, _mediaSourceManager, - _mediaEncoder); + _mediaEncoder, + device); sessionInfo.AddController(controller); - controller.Init(device); - var profile = _dlnaManager.GetProfile(device.Properties.ToDeviceIdentification()) ?? _dlnaManager.GetDefaultProfile(); diff --git a/Emby.Dlna/PlayTo/TransportCommands.cs b/Emby.Dlna/PlayTo/TransportCommands.cs index c463727329..6b2096d9dc 100644 --- a/Emby.Dlna/PlayTo/TransportCommands.cs +++ b/Emby.Dlna/PlayTo/TransportCommands.cs @@ -116,7 +116,7 @@ namespace Emby.Dlna.PlayTo return string.Format(CultureInfo.InvariantCulture, CommandBase, action.Name, xmlNamespace, stateString); } - public string BuildPost(ServiceAction action, string xmlNamesapce, object value, string commandParameter = "") + public string BuildPost(ServiceAction action, string xmlNamespace, object value, string commandParameter = "") { var stateString = string.Empty; @@ -137,10 +137,10 @@ namespace Emby.Dlna.PlayTo } } - return string.Format(CultureInfo.InvariantCulture, CommandBase, action.Name, xmlNamesapce, stateString); + return string.Format(CultureInfo.InvariantCulture, CommandBase, action.Name, xmlNamespace, stateString); } - public string BuildPost(ServiceAction action, string xmlNamesapce, object value, Dictionary dictionary) + public string BuildPost(ServiceAction action, string xmlNamespace, object value, Dictionary dictionary) { var stateString = string.Empty; @@ -150,9 +150,9 @@ namespace Emby.Dlna.PlayTo { stateString += BuildArgumentXml(arg, "0"); } - else if (dictionary.ContainsKey(arg.Name)) + else if (dictionary.TryGetValue(arg.Name, out var argValue)) { - stateString += BuildArgumentXml(arg, dictionary[arg.Name]); + stateString += BuildArgumentXml(arg, argValue); } else { @@ -160,7 +160,7 @@ namespace Emby.Dlna.PlayTo } } - return string.Format(CultureInfo.InvariantCulture, CommandBase, action.Name, xmlNamesapce, stateString); + return string.Format(CultureInfo.InvariantCulture, CommandBase, action.Name, xmlNamespace, stateString); } private string BuildArgumentXml(Argument argument, string? value, string commandParameter = "") diff --git a/Emby.Dlna/Server/DescriptionXmlBuilder.cs b/Emby.Dlna/Server/DescriptionXmlBuilder.cs index d00df781d6..69ef6f6456 100644 --- a/Emby.Dlna/Server/DescriptionXmlBuilder.cs +++ b/Emby.Dlna/Server/DescriptionXmlBuilder.cs @@ -147,11 +147,16 @@ namespace Emby.Dlna.Server } } - private string GetFriendlyName() + internal string GetFriendlyName() { if (string.IsNullOrEmpty(_profile.FriendlyName)) { - return "Jellyfin - " + _serverName; + return _serverName; + } + + if (!_profile.FriendlyName.Contains("${HostName}", StringComparison.OrdinalIgnoreCase)) + { + return _profile.FriendlyName; } var characterList = new List(); @@ -164,13 +169,18 @@ namespace Emby.Dlna.Server } } - var characters = characterList.ToArray(); + var serverName = string.Create( + characterList.Count, + characterList, + (dest, source) => + { + for (int i = 0; i < dest.Length; i++) + { + dest[i] = source[i]; + } + }); - var serverName = new string(characters); - - var name = _profile.FriendlyName?.Replace("${HostName}", serverName, StringComparison.OrdinalIgnoreCase); - - return name ?? string.Empty; + return _profile.FriendlyName.Replace("${HostName}", serverName, StringComparison.OrdinalIgnoreCase); } private void AppendIconList(StringBuilder builder) diff --git a/Emby.Naming/Audio/AlbumParser.cs b/Emby.Naming/Audio/AlbumParser.cs index bbfdccc902..86a5641531 100644 --- a/Emby.Naming/Audio/AlbumParser.cs +++ b/Emby.Naming/Audio/AlbumParser.cs @@ -3,6 +3,7 @@ using System.Globalization; using System.IO; using System.Text.RegularExpressions; using Emby.Naming.Common; +using Jellyfin.Extensions; namespace Emby.Naming.Audio { @@ -58,13 +59,7 @@ namespace Emby.Naming.Audio var tmp = trimmedFilename.Slice(prefix.Length).Trim(); - int index = tmp.IndexOf(' '); - if (index != -1) - { - tmp = tmp.Slice(0, index); - } - - if (int.TryParse(tmp, NumberStyles.Integer, CultureInfo.InvariantCulture, out _)) + if (int.TryParse(tmp.LeftPart(' '), CultureInfo.InvariantCulture, out _)) { return true; } diff --git a/Emby.Naming/AudioBook/AudioBookFilePathParser.cs b/Emby.Naming/AudioBook/AudioBookFilePathParser.cs index 7b4429ab15..75fdedfeab 100644 --- a/Emby.Naming/AudioBook/AudioBookFilePathParser.cs +++ b/Emby.Naming/AudioBook/AudioBookFilePathParser.cs @@ -32,7 +32,7 @@ namespace Emby.Naming.AudioBook var fileName = Path.GetFileNameWithoutExtension(path); foreach (var expression in _options.AudioBookPartsExpressions) { - var match = new Regex(expression, RegexOptions.IgnoreCase).Match(fileName); + var match = Regex.Match(fileName, expression, RegexOptions.IgnoreCase); if (match.Success) { if (!result.ChapterNumber.HasValue) @@ -40,7 +40,7 @@ namespace Emby.Naming.AudioBook var value = match.Groups["chapter"]; if (value.Success) { - if (int.TryParse(value.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intValue)) + if (int.TryParse(value.ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intValue)) { result.ChapterNumber = intValue; } @@ -52,7 +52,7 @@ namespace Emby.Naming.AudioBook var value = match.Groups["part"]; if (value.Success) { - if (int.TryParse(value.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intValue)) + if (int.TryParse(value.ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intValue)) { result.PartNumber = intValue; } diff --git a/Emby.Naming/AudioBook/AudioBookListResolver.cs b/Emby.Naming/AudioBook/AudioBookListResolver.cs index bdae20b6b2..ca304102fd 100644 --- a/Emby.Naming/AudioBook/AudioBookListResolver.cs +++ b/Emby.Naming/AudioBook/AudioBookListResolver.cs @@ -79,25 +79,25 @@ namespace Emby.Naming.AudioBook { if (group.Count() > 1 || haveChaptersOrPages) { - var ex = new List(); - var alt = new List(); + List? ex = null; + List? alt = null; foreach (var audioFile in group) { - var name = Path.GetFileNameWithoutExtension(audioFile.Path); - if (name.Equals("audiobook", StringComparison.OrdinalIgnoreCase) || - name.Contains(nameParserResult.Name, StringComparison.OrdinalIgnoreCase) || - name.Contains(nameWithReplacedDots, StringComparison.OrdinalIgnoreCase)) + var name = Path.GetFileNameWithoutExtension(audioFile.Path.AsSpan()); + if (name.Equals("audiobook", StringComparison.OrdinalIgnoreCase) + || name.Contains(nameParserResult.Name, StringComparison.OrdinalIgnoreCase) + || name.Contains(nameWithReplacedDots, StringComparison.OrdinalIgnoreCase)) { - alt.Add(audioFile); + (alt ??= new()).Add(audioFile); } else { - ex.Add(audioFile); + (ex ??= new()).Add(audioFile); } } - if (ex.Count > 0) + if (ex is not null) { var extra = ex .OrderBy(x => x.Container) @@ -108,7 +108,7 @@ namespace Emby.Naming.AudioBook extras.AddRange(extra); } - if (alt.Count > 0) + if (alt is not null) { var alternatives = alt .OrderBy(x => x.Container) diff --git a/Emby.Naming/AudioBook/AudioBookNameParser.cs b/Emby.Naming/AudioBook/AudioBookNameParser.cs index 97b34199e0..5ea649dbf7 100644 --- a/Emby.Naming/AudioBook/AudioBookNameParser.cs +++ b/Emby.Naming/AudioBook/AudioBookNameParser.cs @@ -30,7 +30,7 @@ namespace Emby.Naming.AudioBook AudioBookNameParserResult result = default; foreach (var expression in _options.AudioBookNamesExpressions) { - var match = new Regex(expression, RegexOptions.IgnoreCase).Match(name); + var match = Regex.Match(name, expression, RegexOptions.IgnoreCase); if (match.Success) { if (result.Name is null) @@ -47,7 +47,7 @@ namespace Emby.Naming.AudioBook var value = match.Groups["year"]; if (value.Success) { - if (int.TryParse(value.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intValue)) + if (int.TryParse(value.ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intValue)) { result.Year = intValue; } diff --git a/Emby.Naming/Common/NamingOptions.cs b/Emby.Naming/Common/NamingOptions.cs index 54f62a1570..a069da1022 100644 --- a/Emby.Naming/Common/NamingOptions.cs +++ b/Emby.Naming/Common/NamingOptions.cs @@ -141,8 +141,7 @@ namespace Emby.Naming.Common VideoFileStackingRules = new[] { new FileStackRule(@"^(?.*?)(?:(?<=[\]\)\}])|[ _.-]+)[\(\[]?(?cd|dvd|part|pt|dis[ck])[ _.-]*(?[0-9]+)[\)\]]?(?:\.[^.]+)?$", true), - new FileStackRule(@"^(?.*?)(?:(?<=[\]\)\}])|[ _.-]+)[\(\[]?(?cd|dvd|part|pt|dis[ck])[ _.-]*(?[a-d])[\)\]]?(?:\.[^.]+)?$", false), - new FileStackRule(@"^(?.*?)(?:(?<=[\]\)\}])|[ _.-]?)(?[a-d])(?:\.[^.]+)?$", false) + new FileStackRule(@"^(?.*?)(?:(?<=[\]\)\}])|[ _.-]+)[\(\[]?(?cd|dvd|part|pt|dis[ck])[ _.-]*(?[a-d])[\)\]]?(?:\.[^.]+)?$", false) }; CleanDateTimes = new[] @@ -157,7 +156,8 @@ namespace Emby.Naming.Common @"^(?.+?)(\[.*\])", @"^\s*(?.+?)\WE[0-9]+(-|~)E?[0-9]+(\W|$)", @"^\s*\[[^\]]+\](?!\.\w+$)\s*(?.+)", - @"^\s*(?.+?)\s+-\s+[0-9]+\s*$" + @"^\s*(?.+?)\s+-\s+[0-9]+\s*$", + @"^\s*(?.+?)(([-._ ](trailer|sample))|-(scene|clip|behindthescenes|deleted|deletedscene|featurette|short|interview|other|extra))$" }; SubtitleFileExtensions = new[] @@ -270,7 +270,6 @@ namespace Emby.Naming.Common ".sfx", ".shn", ".sid", - ".spc", ".stm", ".strm", ".ult", @@ -338,7 +337,15 @@ namespace Emby.Naming.Common } }, - // This isn't a Kodi naming rule, but the expression below causes false positives, + // This isn't a Kodi naming rule, but the expression below causes false episode numbers for + // Title Season X Episode X naming schemes. + // "Series Season X Episode X - Title.avi", "Series S03 E09.avi", "s3 e9 - Title.avi" + new EpisodeExpression(@".*[\\\/]((?[^\\/]+?)\s)?[Ss](?:eason)?\s*(?[0-9]+)\s+[Ee](?:pisode)?\s*(?[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)(?[\w\s]+?)\s(?[0-9]{1,4})(-(?[0-9]{2,4}))*[^\\\/x]*$") @@ -453,16 +460,6 @@ namespace Emby.Naming.Common }, }; - EpisodeWithoutSeasonExpressions = new[] - { - @"[/\._ \-]()([0-9]+)(-[0-9]+)?" - }; - - EpisodeMultiPartExpressions = new[] - { - @"^[-_ex]+([0-9]+(?:(?:[a-i]|\\.[1-9])(?![0-9]))?)" - }; - VideoExtraRules = new[] { new ExtraRule( @@ -797,16 +794,6 @@ namespace Emby.Naming.Common /// public EpisodeExpression[] EpisodeExpressions { get; set; } - /// - /// Gets or sets list of raw episode without season regular expressions strings. - /// - public string[] EpisodeWithoutSeasonExpressions { get; set; } - - /// - /// Gets or sets list of raw multi-part episodes regular expressions strings. - /// - public string[] EpisodeMultiPartExpressions { get; set; } - /// /// Gets or sets list of video file extensions. /// @@ -877,16 +864,6 @@ namespace Emby.Naming.Common /// public Regex[] CleanStringRegexes { get; private set; } = Array.Empty(); - /// - /// Gets list of episode without season regular expressions. - /// - public Regex[] EpisodeWithoutSeasonRegexes { get; private set; } = Array.Empty(); - - /// - /// Gets list of multi-part episode regular expressions. - /// - public Regex[] EpisodeMultiPartRegexes { get; private set; } = Array.Empty(); - /// /// Compiles raw regex strings into regexes. /// @@ -894,8 +871,6 @@ namespace Emby.Naming.Common { CleanDateTimeRegexes = CleanDateTimes.Select(Compile).ToArray(); CleanStringRegexes = CleanStrings.Select(Compile).ToArray(); - EpisodeWithoutSeasonRegexes = EpisodeWithoutSeasonExpressions.Select(Compile).ToArray(); - EpisodeMultiPartRegexes = EpisodeMultiPartExpressions.Select(Compile).ToArray(); } private Regex Compile(string exp) diff --git a/Emby.Naming/Emby.Naming.csproj b/Emby.Naming/Emby.Naming.csproj index 3106e22465..f3973dad95 100644 --- a/Emby.Naming/Emby.Naming.csproj +++ b/Emby.Naming/Emby.Naming.csproj @@ -42,18 +42,18 @@ - + - + all runtime; build; native; contentfiles; analyzers - - - + + + diff --git a/Emby.Naming/TV/EpisodePathParser.cs b/Emby.Naming/TV/EpisodePathParser.cs index d706be2802..8cd5a126e0 100644 --- a/Emby.Naming/TV/EpisodePathParser.cs +++ b/Emby.Naming/TV/EpisodePathParser.cs @@ -113,7 +113,7 @@ namespace Emby.Naming.TV if (expression.DateTimeFormats.Length > 0) { if (DateTime.TryParseExact( - match.Groups[0].Value, + match.Groups[0].ValueSpan, expression.DateTimeFormats, CultureInfo.InvariantCulture, DateTimeStyles.None, @@ -125,7 +125,7 @@ namespace Emby.Naming.TV result.Success = true; } } - else if (DateTime.TryParse(match.Groups[0].Value, out date)) + else if (DateTime.TryParse(match.Groups[0].ValueSpan, out date)) { result.Year = date.Year; result.Month = date.Month; @@ -138,12 +138,12 @@ namespace Emby.Naming.TV } else if (expression.IsNamed) { - if (int.TryParse(match.Groups["seasonnumber"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var num)) + if (int.TryParse(match.Groups["seasonnumber"].ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var num)) { result.SeasonNumber = num; } - if (int.TryParse(match.Groups["epnumber"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out num)) + if (int.TryParse(match.Groups["epnumber"].ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out num)) { result.EpisodeNumber = num; } @@ -158,7 +158,7 @@ namespace Emby.Naming.TV if (nextIndex >= name.Length || !"0123456789iIpP".Contains(name[nextIndex], StringComparison.Ordinal)) { - if (int.TryParse(endingNumberGroup.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out num)) + if (int.TryParse(endingNumberGroup.ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out num)) { result.EndingEpisodeNumber = num; } @@ -170,12 +170,12 @@ namespace Emby.Naming.TV } else { - if (int.TryParse(match.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var num)) + if (int.TryParse(match.Groups[1].ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var num)) { result.SeasonNumber = num; } - if (int.TryParse(match.Groups[2].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out num)) + if (int.TryParse(match.Groups[2].ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out num)) { result.EpisodeNumber = num; } diff --git a/Emby.Naming/TV/SeriesResolver.cs b/Emby.Naming/TV/SeriesResolver.cs index 156a03c9ed..307a840964 100644 --- a/Emby.Naming/TV/SeriesResolver.cs +++ b/Emby.Naming/TV/SeriesResolver.cs @@ -14,7 +14,7 @@ namespace Emby.Naming.TV /// Used for removing separators between words, i.e turns "The_show" into "The show" while /// preserving namings like "S.H.O.W". /// - private static readonly Regex _seriesNameRegex = new Regex(@"((?[^\._]{2,})[\._]*)|([\._](?[^\._]{2,}))"); + private static readonly Regex _seriesNameRegex = new Regex(@"((?[^\._]{2,})[\._]*)|([\._](?[^\._]{2,}))", RegexOptions.Compiled); /// /// Resolve information about series from path. diff --git a/Emby.Naming/Video/CleanDateTimeParser.cs b/Emby.Naming/Video/CleanDateTimeParser.cs index 0ee633dcc6..9a6c6e978b 100644 --- a/Emby.Naming/Video/CleanDateTimeParser.cs +++ b/Emby.Naming/Video/CleanDateTimeParser.cs @@ -43,7 +43,7 @@ namespace Emby.Naming.Video && match.Groups.Count == 5 && match.Groups[1].Success && match.Groups[2].Success - && int.TryParse(match.Groups[2].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year)) + && int.TryParse(match.Groups[2].ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year)) { result = new CleanDateTimeResult(match.Groups[1].Value.TrimEnd(), year); return true; diff --git a/Emby.Naming/Video/ExtraRuleResolver.cs b/Emby.Naming/Video/ExtraRuleResolver.cs index 21d0da3642..3219472eff 100644 --- a/Emby.Naming/Video/ExtraRuleResolver.cs +++ b/Emby.Naming/Video/ExtraRuleResolver.cs @@ -56,7 +56,7 @@ namespace Emby.Naming.Video } else if (rule.RuleType == ExtraRuleType.Regex) { - var filename = Path.GetFileName(path); + var filename = Path.GetFileName(path.AsSpan()); var isMatch = Regex.IsMatch(filename, rule.Token, RegexOptions.IgnoreCase | RegexOptions.Compiled); diff --git a/Emby.Naming/Video/FileStackRule.cs b/Emby.Naming/Video/FileStackRule.cs index 76b487f428..be0f79d33a 100644 --- a/Emby.Naming/Video/FileStackRule.cs +++ b/Emby.Naming/Video/FileStackRule.cs @@ -17,7 +17,7 @@ public class FileStackRule /// Whether the file stack rule uses numerical or alphabetical numbering. public FileStackRule(string token, bool isNumerical) { - _tokenRegex = new Regex(token, RegexOptions.IgnoreCase); + _tokenRegex = new Regex(token, RegexOptions.IgnoreCase | RegexOptions.Compiled); IsNumerical = isNumerical; } diff --git a/Emby.Naming/Video/VideoListResolver.cs b/Emby.Naming/Video/VideoListResolver.cs index 8048320400..6209cd46f4 100644 --- a/Emby.Naming/Video/VideoListResolver.cs +++ b/Emby.Naming/Video/VideoListResolver.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using System.Text.RegularExpressions; using Emby.Naming.Common; +using Jellyfin.Extensions; using MediaBrowser.Model.IO; namespace Emby.Naming.Video @@ -13,6 +14,8 @@ namespace Emby.Naming.Video /// public static class VideoListResolver { + private static readonly Regex _resolutionRegex = new Regex("[0-9]{2}[0-9]+[ip]", RegexOptions.IgnoreCase | RegexOptions.Compiled); + /// /// Resolves alternative versions and extras from list of video files. /// @@ -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; + } } - // 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)); + 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())); + } + } + } + + primary ??= videos[0]; + videos.Remove(primary); var list = new List { - 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 folderName, string testFilePath, NamingOptions namingOptions) + private static bool IsEligibleForMultiVersion(ReadOnlySpan folderName, ReadOnlySpan testFilename, NamingOptions namingOptions) { - var testFilename = Path.GetFileNameWithoutExtension(testFilePath.AsSpan()); if (!testFilename.StartsWith(folderName, StringComparison.OrdinalIgnoreCase)) { return false; @@ -176,16 +193,15 @@ namespace Emby.Naming.Video } // There are no span overloads for regex unfortunately - var tmpTestFilename = testFilename.ToString(); - if (CleanStringParser.TryClean(tmpTestFilename, namingOptions.CleanStringRegexes, out var cleanName)) + if (CleanStringParser.TryClean(testFilename.ToString(), namingOptions.CleanStringRegexes, out var cleanName)) { - tmpTestFilename = cleanName.Trim(); + testFilename = cleanName.AsSpan().Trim(); } // The CleanStringParser should have removed common keywords etc. - return string.IsNullOrEmpty(tmpTestFilename) + return testFilename.IsEmpty || testFilename[0] == '-' - || Regex.IsMatch(tmpTestFilename, @"^\[([^]]*)\]", RegexOptions.Compiled); + || Regex.IsMatch(testFilename, @"^\[([^]]*)\]", RegexOptions.Compiled); } } } diff --git a/Emby.Naming/Video/VideoResolver.cs b/Emby.Naming/Video/VideoResolver.cs index 858e9dd2f5..db5bfdbf94 100644 --- a/Emby.Naming/Video/VideoResolver.cs +++ b/Emby.Naming/Video/VideoResolver.cs @@ -87,8 +87,7 @@ namespace Emby.Naming.Video name = cleanDateTimeResult.Name; year = cleanDateTimeResult.Year; - if (extraResult.ExtraType is null - && TryCleanString(name, namingOptions, out var newName)) + if (TryCleanString(name, namingOptions, out var newName)) { name = newName; } diff --git a/Emby.Photos/Emby.Photos.csproj b/Emby.Photos/Emby.Photos.csproj index ae6bc2db1f..0f97a06867 100644 --- a/Emby.Photos/Emby.Photos.csproj +++ b/Emby.Photos/Emby.Photos.csproj @@ -15,7 +15,7 @@ - + @@ -26,13 +26,13 @@ - + all runtime; build; native; contentfiles; analyzers - - - + + + diff --git a/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs b/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs index 985a127d50..a4deeddb78 100644 --- a/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs +++ b/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Collections.Concurrent; using System.Collections.Generic; @@ -33,15 +31,10 @@ namespace Emby.Server.Implementations.AppBase private ConfigurationStore[] _configurationStores = Array.Empty(); private IConfigurationFactory[] _configurationFactories = Array.Empty(); - /// - /// The _configuration loaded. - /// - private bool _configurationLoaded; - /// /// The _configuration. /// - private BaseApplicationConfiguration _configuration; + private BaseApplicationConfiguration? _configuration; /// /// Initializes a new instance of the class. @@ -63,17 +56,17 @@ namespace Emby.Server.Implementations.AppBase /// /// Occurs when [configuration updated]. /// - public event EventHandler ConfigurationUpdated; + public event EventHandler? ConfigurationUpdated; /// /// Occurs when [configuration updating]. /// - public event EventHandler NamedConfigurationUpdating; + public event EventHandler? NamedConfigurationUpdating; /// /// Occurs when [named configuration updated]. /// - public event EventHandler NamedConfigurationUpdated; + public event EventHandler? NamedConfigurationUpdated; /// /// 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); + if (File.Exists(path)) + { + return XmlSerializer.DeserializeFromFile(configurationType, path); + } } - catch (IOException) - { - return Activator.CreateInstance(configurationType); - } - 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."); } /// @@ -367,7 +349,7 @@ namespace Emby.Server.Implementations.AppBase _configurations.AddOrUpdate(key, configuration, (_, _) => configuration); var path = GetConfigurationFile(key); - Directory.CreateDirectory(Path.GetDirectoryName(path)); + Directory.CreateDirectory(Path.GetDirectoryName(path) ?? throw new InvalidOperationException("Path can't be a root directory.")); lock (_configurationSyncLock) { diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 07b0807b72..7969577bc0 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -11,7 +11,6 @@ using System.IO; using System.Linq; using System.Net; using System.Reflection; -using System.Runtime.InteropServices; using System.Security.Cryptography.X509Certificates; using System.Threading; using System.Threading.Tasks; @@ -81,11 +80,13 @@ using MediaBrowser.Controller.Subtitles; using MediaBrowser.Controller.SyncPlay; using MediaBrowser.Controller.TV; using MediaBrowser.LocalMetadata.Savers; +using MediaBrowser.MediaEncoding.BdInfo; using MediaBrowser.MediaEncoding.Subtitles; using MediaBrowser.Model.Cryptography; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; +using MediaBrowser.Model.MediaInfo; using MediaBrowser.Model.Net; using MediaBrowser.Model.Serialization; using MediaBrowser.Model.System; @@ -113,15 +114,11 @@ namespace Emby.Server.Implementations /// public abstract class ApplicationHost : IServerApplicationHost, IAsyncDisposable, IDisposable { - /// - /// The environment variable prefixes to log at server startup. - /// - private static readonly string[] _relevantEnvVarPrefixes = { "JELLYFIN_", "DOTNET_", "ASPNETCORE_" }; - /// /// The disposable parts. /// private readonly ConcurrentDictionary _disposableParts = new(); + private readonly DeviceId _deviceId; private readonly IFileSystem _fileSystemManager; private readonly IConfiguration _startupConfig; @@ -130,7 +127,6 @@ namespace Emby.Server.Implementations private readonly IPluginManager _pluginManager; private List _creatingInstances; - private IMediaEncoder _mediaEncoder; private ISessionManager _sessionManager; /// @@ -139,8 +135,6 @@ namespace Emby.Server.Implementations /// All concrete types. private Type[] _allConcreteTypes; - private DeviceId _deviceId; - private bool _disposed = false; /// @@ -164,6 +158,7 @@ namespace Emby.Server.Implementations Logger = LoggerFactory.CreateLogger(); _fileSystemManager.AddShortcutHandler(new MbLinkShortcutHandler(_fileSystemManager)); + _deviceId = new DeviceId(ApplicationPaths, LoggerFactory); ApplicationVersion = typeof(ApplicationHost).Assembly.GetName().Version; ApplicationVersionString = ApplicationVersion.ToString(3); @@ -191,23 +186,9 @@ namespace Emby.Server.Implementations public bool CoreStartupHasCompleted { get; private set; } - public virtual bool CanLaunchWebBrowser - { - get - { - if (!Environment.UserInteractive) - { - return false; - } - - if (_startupOptions.IsService) - { - return false; - } - - return OperatingSystem.IsWindows() || OperatingSystem.IsMacOS(); - } - } + public virtual bool CanLaunchWebBrowser => Environment.UserInteractive + && !_startupOptions.IsService + && (OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()); /// /// Gets the singleton instance. @@ -284,15 +265,7 @@ namespace Emby.Server.Implementations /// The application name. 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; /// public string Name => ApplicationProductName; @@ -445,7 +418,7 @@ namespace Emby.Server.Implementations ConfigurationManager.ConfigurationUpdated += OnConfigurationUpdated; ConfigurationManager.NamedConfigurationUpdated += OnConfigurationUpdated; - _mediaEncoder.SetFFmpegPath(); + Resolve().SetFFmpegPath(); Logger.LogInformation("ServerId: {ServerId}", SystemId); @@ -558,6 +531,8 @@ namespace Emby.Server.Implementations serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); @@ -652,50 +627,19 @@ namespace Emby.Server.Implementations } } + ((SqliteItemRepository)Resolve()).Initialize(); + ((SqliteUserDataRepository)Resolve()).Initialize(); + var localizationManager = (LocalizationManager)Resolve(); await localizationManager.LoadAll().ConfigureAwait(false); - _mediaEncoder = Resolve(); _sessionManager = Resolve(); SetStaticProperties(); - var userDataRepo = (SqliteUserDataRepository)Resolve(); - ((SqliteItemRepository)Resolve()).Initialize(userDataRepo, Resolve()); - 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(); - foreach (var key in allEnvVars.Keys) - { - if (_relevantEnvVarPrefixes.Any(prefix => key.ToString().StartsWith(prefix, StringComparison.OrdinalIgnoreCase))) - { - relevantEnvVars.Add(key, allEnvVars[key]); - } - } - - logger.LogInformation("Environment Variables: {EnvVars}", relevantEnvVars); - logger.LogInformation("Arguments: {Args}", commandLineArgs); - logger.LogInformation("Operating system: {OS}", RuntimeInformation.OSDescription); - logger.LogInformation("Architecture: {Architecture}", RuntimeInformation.OSArchitecture); - logger.LogInformation("64-Bit Process: {Is64Bit}", Environment.Is64BitProcess); - logger.LogInformation("User Interactive: {IsUserInteractive}", Environment.UserInteractive); - logger.LogInformation("Processor count: {ProcessorCount}", Environment.ProcessorCount); - logger.LogInformation("Program data path: {ProgramDataPath}", appPaths.ProgramDataPath); - logger.LogInformation("Web resources path: {WebPath}", appPaths.WebPath); - logger.LogInformation("Application directory: {ApplicationPath}", appPaths.ProgramSystemPath); - } - private X509Certificate2 GetCertificate(string path, string password) { if (string.IsNullOrWhiteSpace(path)) @@ -782,10 +726,6 @@ namespace Emby.Server.Implementations Resolve().AddParts(GetExports(), GetExports(), GetExports()); - Resolve().AddParts(GetExports()); - - Resolve().AddParts(GetExports()); - Resolve().AddParts(GetExports()); } @@ -1248,10 +1188,13 @@ namespace Emby.Server.Implementations } } - // used for closing websockets - foreach (var session in _sessionManager.Sessions) + if (_sessionManager != null) { - await session.DisposeAsync().ConfigureAwait(false); + // used for closing websockets + foreach (var session in _sessionManager.Sessions) + { + await session.DisposeAsync().ConfigureAwait(false); + } } } } diff --git a/Emby.Server.Implementations/Channels/ChannelManager.cs b/Emby.Server.Implementations/Channels/ChannelManager.cs index 85ccbc0284..961e225e9e 100644 --- a/Emby.Server.Implementations/Channels/ChannelManager.cs +++ b/Emby.Server.Implementations/Channels/ChannelManager.cs @@ -66,6 +66,7 @@ namespace Emby.Server.Implementations.Channels /// The user data manager. /// The provider manager. /// The memory cache. + /// The channels. 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 channels) { _userManager = userManager; _dtoService = dtoService; @@ -86,18 +88,13 @@ namespace Emby.Server.Implementations.Channels _userDataManager = userDataManager; _providerManager = providerManager; _memoryCache = memoryCache; - } - - internal IChannel[] Channels { get; private set; } - - private static TimeSpan CacheLength => TimeSpan.FromHours(3); - - /// - public void AddParts(IEnumerable channels) - { Channels = channels.ToArray(); } + internal IChannel[] Channels { get; } + + private static TimeSpan CacheLength => TimeSpan.FromHours(3); + /// public bool EnableMediaSourceDisplay(BaseItem item) { @@ -160,16 +157,16 @@ namespace Emby.Server.Implementations.Channels } /// - public QueryResult GetChannelsInternal(ChannelQuery query) + public async Task> 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 } /// - public QueryResult GetChannels(ChannelQuery query) + public async Task> 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 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(); + results = Enumerable.Empty(); } return results diff --git a/Emby.Server.Implementations/Collections/CollectionManager.cs b/Emby.Server.Implementations/Collections/CollectionManager.cs index b53c8ca512..b34d0f21ef 100644 --- a/Emby.Server.Implementations/Collections/CollectionManager.cs +++ b/Emby.Server.Implementations/Collections/CollectionManager.cs @@ -112,7 +112,8 @@ namespace Emby.Server.Implementations.Collections return Path.Combine(_appPaths.DataPath, "collections"); } - private Task GetCollectionsFolder(bool createIfNeeded) + /// + public Task GetCollectionsFolder(bool createIfNeeded) { return EnsureLibraryFolder(GetCollectionsFolderPath(), createIfNeeded); } @@ -206,8 +207,7 @@ namespace Emby.Server.Implementations.Collections throw new ArgumentException("No collection exists with the supplied Id"); } - var list = new List(); - var itemList = new List(); + List? itemList = null; var linkedChildrenList = collection.GetLinkedChildren(); var currentLinkedChildrenIds = linkedChildrenList.Select(i => i.Id).ToList(); @@ -223,18 +223,23 @@ namespace Emby.Server.Implementations.Collections if (!currentLinkedChildrenIds.Contains(id)) { - itemList.Add(item); + (itemList ??= new()).Add(item); - list.Add(LinkedChild.Create(item)); linkedChildrenList.Add(item); } } - if (list.Count > 0) + if (itemList is not null) { - LinkedChild[] newChildren = new LinkedChild[collection.LinkedChildren.Length + list.Count]; + var originalLen = collection.LinkedChildren.Length; + var newItemCount = itemList.Count; + LinkedChild[] newChildren = new LinkedChild[originalLen + newItemCount]; collection.LinkedChildren.CopyTo(newChildren, 0); - list.CopyTo(newChildren, collection.LinkedChildren.Length); + for (int i = 0; i < newItemCount; i++) + { + newChildren[originalLen + i] = LinkedChild.Create(itemList[i]); + } + collection.LinkedChildren = newChildren; collection.UpdateRatingToItems(linkedChildrenList); diff --git a/Emby.Server.Implementations/Configuration/ServerConfigurationManager.cs b/Emby.Server.Implementations/Configuration/ServerConfigurationManager.cs index ff5602f243..6b8b1a620f 100644 --- a/Emby.Server.Implementations/Configuration/ServerConfigurationManager.cs +++ b/Emby.Server.Implementations/Configuration/ServerConfigurationManager.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Globalization; using System.IO; @@ -36,7 +34,7 @@ namespace Emby.Server.Implementations.Configuration /// /// Configuration updating event. /// - public event EventHandler> ConfigurationUpdating; + public event EventHandler>? ConfigurationUpdating; /// /// Gets the type of the configuration. diff --git a/Emby.Server.Implementations/ConfigurationOptions.cs b/Emby.Server.Implementations/ConfigurationOptions.cs index f0a4c8ffbd..f0c2676279 100644 --- a/Emby.Server.Implementations/ConfigurationOptions.cs +++ b/Emby.Server.Implementations/ConfigurationOptions.cs @@ -11,14 +11,15 @@ namespace Emby.Server.Implementations /// /// Gets a new copy of the default configuration options. /// - public static Dictionary DefaultConfiguration => new Dictionary + public static Dictionary DefaultConfiguration => new() { { HostWebClientKey, bool.TrueString }, - { DefaultRedirectKey, "web/index.html" }, + { DefaultRedirectKey, "web/" }, { FfmpegProbeSizeKey, "1G" }, { FfmpegAnalyzeDurationKey, "200M" }, { PlaylistsAllowDuplicatesKey, bool.FalseString }, - { BindToUnixSocketKey, bool.FalseString } + { BindToUnixSocketKey, bool.FalseString }, + { SqliteCacheSizeKey, "20000" } }; } } diff --git a/Emby.Server.Implementations/Data/BaseSqliteRepository.cs b/Emby.Server.Implementations/Data/BaseSqliteRepository.cs index 1d61667f86..d05534ee75 100644 --- a/Emby.Server.Implementations/Data/BaseSqliteRepository.cs +++ b/Emby.Server.Implementations/Data/BaseSqliteRepository.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; -using System.Threading; using Jellyfin.Extensions; using Microsoft.Extensions.Logging; using SQLitePCL.pretty; @@ -27,9 +26,19 @@ namespace Emby.Server.Implementations.Data /// /// Gets or sets the path to the DB file. /// - /// Path to the DB file. protected string DbFilePath { get; set; } + /// + /// Gets or sets the number of write connections to create. + /// + /// Path to the DB file. + protected int WriteConnectionsCount { get; set; } = 1; + + /// + /// Gets or sets the number of read connections to create. + /// + protected int ReadConnectionsCount { get; set; } = 1; + /// /// Gets the logger. /// @@ -63,7 +72,7 @@ namespace Emby.Server.Implementations.Data /// /// Gets the locking mode. . /// - protected virtual string LockingMode => "EXCLUSIVE"; + protected virtual string LockingMode => "NORMAL"; /// /// Gets the journal mode. . @@ -73,9 +82,10 @@ namespace Emby.Server.Implementations.Data /// /// Gets the journal size limit. . + /// The default (-1) is overriden to prevent unconstrained WAL size, as reported by users. /// /// The journal size limit. - protected virtual int? JournalSizeLimit => 0; + protected virtual int? JournalSizeLimit => 134_217_728; // 128MiB /// /// Gets the page size. @@ -88,7 +98,7 @@ namespace Emby.Server.Implementations.Data /// /// The temp store mode. /// - protected virtual TempStoreMode TempStore => TempStoreMode.Default; + protected virtual TempStoreMode TempStore => TempStoreMode.Memory; /// /// Gets the synchronous mode. @@ -101,63 +111,106 @@ namespace Emby.Server.Implementations.Data /// Gets or sets the write lock. /// /// The write lock. - protected SemaphoreSlim WriteLock { get; set; } = new SemaphoreSlim(1, 1); + protected ConnectionPool WriteConnections { get; set; } /// /// Gets or sets the write connection. /// /// The write connection. - protected SQLiteDatabaseConnection WriteConnection { get; set; } + protected ConnectionPool ReadConnections { get; set; } + + public virtual void Initialize() + { + WriteConnections = new ConnectionPool(WriteConnectionsCount, CreateWriteConnection); + ReadConnections = new ConnectionPool(ReadConnectionsCount, CreateReadConnection); + + // Configuration and pragmas can affect VACUUM so it needs to be last. + using (var connection = GetConnection()) + { + connection.Execute("VACUUM"); + } + } protected ManagedConnection GetConnection(bool readOnly = false) - { - WriteLock.Wait(); - if (WriteConnection is not null) - { - return new ManagedConnection(WriteConnection, WriteLock); - } + => readOnly ? ReadConnections.GetConnection() : WriteConnections.GetConnection(); - WriteConnection = SQLite3.Open( + protected SQLiteDatabaseConnection CreateWriteConnection() + { + var writeConnection = SQLite3.Open( DbFilePath, DefaultConnectionFlags | ConnectionFlags.Create | ConnectionFlags.ReadWrite, null); if (CacheSize.HasValue) { - WriteConnection.Execute("PRAGMA cache_size=" + CacheSize.Value); + writeConnection.Execute("PRAGMA cache_size=" + CacheSize.Value); } if (!string.IsNullOrWhiteSpace(LockingMode)) { - WriteConnection.Execute("PRAGMA locking_mode=" + LockingMode); + writeConnection.Execute("PRAGMA locking_mode=" + LockingMode); } if (!string.IsNullOrWhiteSpace(JournalMode)) { - WriteConnection.Execute("PRAGMA journal_mode=" + JournalMode); + writeConnection.Execute("PRAGMA journal_mode=" + JournalMode); } if (JournalSizeLimit.HasValue) { - WriteConnection.Execute("PRAGMA journal_size_limit=" + (int)JournalSizeLimit.Value); + writeConnection.Execute("PRAGMA journal_size_limit=" + JournalSizeLimit.Value); } if (Synchronous.HasValue) { - WriteConnection.Execute("PRAGMA synchronous=" + (int)Synchronous.Value); + writeConnection.Execute("PRAGMA synchronous=" + (int)Synchronous.Value); } if (PageSize.HasValue) { - WriteConnection.Execute("PRAGMA page_size=" + PageSize.Value); + writeConnection.Execute("PRAGMA page_size=" + PageSize.Value); } - WriteConnection.Execute("PRAGMA temp_store=" + (int)TempStore); + writeConnection.Execute("PRAGMA temp_store=" + (int)TempStore); - // Configuration and pragmas can affect VACUUM so it needs to be last. - WriteConnection.Execute("VACUUM"); + return writeConnection; + } - return new ManagedConnection(WriteConnection, WriteLock); + protected SQLiteDatabaseConnection CreateReadConnection() + { + var connection = SQLite3.Open( + DbFilePath, + DefaultConnectionFlags | ConnectionFlags.ReadOnly, + null); + + if (CacheSize.HasValue) + { + connection.Execute("PRAGMA cache_size=" + CacheSize.Value); + } + + if (!string.IsNullOrWhiteSpace(LockingMode)) + { + connection.Execute("PRAGMA locking_mode=" + LockingMode); + } + + if (!string.IsNullOrWhiteSpace(JournalMode)) + { + connection.Execute("PRAGMA journal_mode=" + JournalMode); + } + + if (JournalSizeLimit.HasValue) + { + connection.Execute("PRAGMA journal_size_limit=" + JournalSizeLimit.Value); + } + + if (Synchronous.HasValue) + { + connection.Execute("PRAGMA synchronous=" + (int)Synchronous.Value); + } + + connection.Execute("PRAGMA temp_store=" + (int)TempStore); + + return connection; } public IStatement PrepareStatement(ManagedConnection connection, string sql) @@ -166,18 +219,6 @@ namespace Emby.Server.Implementations.Data public IStatement PrepareStatement(IDatabaseConnection connection, string sql) => connection.PrepareStatement(sql); - public IStatement[] PrepareAll(IDatabaseConnection connection, IReadOnlyList sql) - { - int len = sql.Count; - IStatement[] statements = new IStatement[len]; - for (int i = 0; i < len; i++) - { - statements[i] = connection.PrepareStatement(sql[i]); - } - - return statements; - } - protected bool TableExists(ManagedConnection connection, string name) { return connection.RunInTransaction( @@ -252,22 +293,10 @@ namespace Emby.Server.Implementations.Data if (dispose) { - WriteLock.Wait(); - try - { - WriteConnection?.Dispose(); - } - finally - { - WriteLock.Release(); - } - - WriteLock.Dispose(); + WriteConnections.Dispose(); + ReadConnections.Dispose(); } - WriteConnection = null; - WriteLock = null; - _disposed = true; } } diff --git a/Emby.Server.Implementations/Data/ConnectionPool.cs b/Emby.Server.Implementations/Data/ConnectionPool.cs new file mode 100644 index 0000000000..5ea7e934ff --- /dev/null +++ b/Emby.Server.Implementations/Data/ConnectionPool.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Concurrent; +using SQLitePCL.pretty; + +namespace Emby.Server.Implementations.Data; + +/// +/// A pool of SQLite Database connections. +/// +public sealed class ConnectionPool : IDisposable +{ + private readonly BlockingCollection _connections = new(); + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + /// The number of database connection to create. + /// Factory function to create the database connections. + public ConnectionPool(int count, Func factory) + { + for (int i = 0; i < count; i++) + { + _connections.Add(factory.Invoke()); + } + } + + /// + /// Gets a database connection from the pool if one is available, otherwise blocks. + /// + /// A database connection. + public ManagedConnection GetConnection() + { + if (_disposed) + { + ThrowObjectDisposedException(); + } + + return new ManagedConnection(_connections.Take(), this); + + static void ThrowObjectDisposedException() + { + throw new ObjectDisposedException(nameof(ConnectionPool)); + } + } + + /// + /// Return a database connection to the pool. + /// + /// The database connection to return. + public void Return(SQLiteDatabaseConnection connection) + { + if (_disposed) + { + connection.Dispose(); + return; + } + + _connections.Add(connection); + } + + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + foreach (var connection in _connections) + { + connection.Dispose(); + } + + _connections.Dispose(); + + _disposed = true; + } +} diff --git a/Emby.Server.Implementations/Data/ManagedConnection.cs b/Emby.Server.Implementations/Data/ManagedConnection.cs index 11e33278d4..e84ed8f918 100644 --- a/Emby.Server.Implementations/Data/ManagedConnection.cs +++ b/Emby.Server.Implementations/Data/ManagedConnection.cs @@ -2,23 +2,22 @@ using System; using System.Collections.Generic; -using System.Threading; using SQLitePCL.pretty; namespace Emby.Server.Implementations.Data { public sealed class ManagedConnection : IDisposable { - private readonly SemaphoreSlim _writeLock; + private readonly ConnectionPool _pool; - private SQLiteDatabaseConnection? _db; + private SQLiteDatabaseConnection _db; private bool _disposed = false; - public ManagedConnection(SQLiteDatabaseConnection db, SemaphoreSlim writeLock) + public ManagedConnection(SQLiteDatabaseConnection db, ConnectionPool pool) { _db = db; - _writeLock = writeLock; + _pool = pool; } public IStatement PrepareStatement(string sql) @@ -73,9 +72,9 @@ namespace Emby.Server.Implementations.Data return; } - _writeLock.Release(); + _pool.Return(_db); - _db = null; // Don't dispose it + _db = null!; // Don't dispose it _disposed = true; } } diff --git a/Emby.Server.Implementations/Data/SqliteItemRepository.cs b/Emby.Server.Implementations/Data/SqliteItemRepository.cs index bc703fe90d..ca8f605a02 100644 --- a/Emby.Server.Implementations/Data/SqliteItemRepository.cs +++ b/Emby.Server.Implementations/Data/SqliteItemRepository.cs @@ -25,6 +25,7 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Extensions; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.Persistence; @@ -34,6 +35,7 @@ using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.LiveTv; using MediaBrowser.Model.Querying; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using SQLitePCL.pretty; @@ -49,8 +51,8 @@ namespace Emby.Server.Implementations.Data private const string SaveItemCommandText = @"replace into TypedBaseItems - (guid,type,data,Path,StartDate,EndDate,ChannelId,IsMovie,IsSeries,EpisodeTitle,IsRepeat,CommunityRating,CustomRating,IndexNumber,IsLocked,Name,OfficialRating,MediaType,Overview,ParentIndexNumber,PremiereDate,ProductionYear,ParentId,Genres,InheritedParentalRatingValue,SortName,ForcedSortName,RunTimeTicks,Size,DateCreated,DateModified,PreferredMetadataLanguage,PreferredMetadataCountryCode,Width,Height,DateLastRefreshed,DateLastSaved,IsInMixedFolder,LockedFields,Studios,Audio,ExternalServiceId,Tags,IsFolder,UnratedType,TopParentId,TrailerTypes,CriticRating,CleanName,PresentationUniqueKey,OriginalTitle,PrimaryVersionId,DateLastMediaAdded,Album,IsVirtualItem,SeriesName,UserDataKey,SeasonName,SeasonId,SeriesId,ExternalSeriesId,Tagline,ProviderIds,Images,ProductionLocations,ExtraIds,TotalBitrate,ExtraType,Artists,AlbumArtists,ExternalId,SeriesPresentationUniqueKey,ShowId,OwnerId) - values (@guid,@type,@data,@Path,@StartDate,@EndDate,@ChannelId,@IsMovie,@IsSeries,@EpisodeTitle,@IsRepeat,@CommunityRating,@CustomRating,@IndexNumber,@IsLocked,@Name,@OfficialRating,@MediaType,@Overview,@ParentIndexNumber,@PremiereDate,@ProductionYear,@ParentId,@Genres,@InheritedParentalRatingValue,@SortName,@ForcedSortName,@RunTimeTicks,@Size,@DateCreated,@DateModified,@PreferredMetadataLanguage,@PreferredMetadataCountryCode,@Width,@Height,@DateLastRefreshed,@DateLastSaved,@IsInMixedFolder,@LockedFields,@Studios,@Audio,@ExternalServiceId,@Tags,@IsFolder,@UnratedType,@TopParentId,@TrailerTypes,@CriticRating,@CleanName,@PresentationUniqueKey,@OriginalTitle,@PrimaryVersionId,@DateLastMediaAdded,@Album,@IsVirtualItem,@SeriesName,@UserDataKey,@SeasonName,@SeasonId,@SeriesId,@ExternalSeriesId,@Tagline,@ProviderIds,@Images,@ProductionLocations,@ExtraIds,@TotalBitrate,@ExtraType,@Artists,@AlbumArtists,@ExternalId,@SeriesPresentationUniqueKey,@ShowId,@OwnerId)"; + (guid,type,data,Path,StartDate,EndDate,ChannelId,IsMovie,IsSeries,EpisodeTitle,IsRepeat,CommunityRating,CustomRating,IndexNumber,IsLocked,Name,OfficialRating,MediaType,Overview,ParentIndexNumber,PremiereDate,ProductionYear,ParentId,Genres,InheritedParentalRatingValue,SortName,ForcedSortName,RunTimeTicks,Size,DateCreated,DateModified,PreferredMetadataLanguage,PreferredMetadataCountryCode,Width,Height,DateLastRefreshed,DateLastSaved,IsInMixedFolder,LockedFields,Studios,Audio,ExternalServiceId,Tags,IsFolder,UnratedType,TopParentId,TrailerTypes,CriticRating,CleanName,PresentationUniqueKey,OriginalTitle,PrimaryVersionId,DateLastMediaAdded,Album,LUFS,IsVirtualItem,SeriesName,UserDataKey,SeasonName,SeasonId,SeriesId,ExternalSeriesId,Tagline,ProviderIds,Images,ProductionLocations,ExtraIds,TotalBitrate,ExtraType,Artists,AlbumArtists,ExternalId,SeriesPresentationUniqueKey,ShowId,OwnerId) + values (@guid,@type,@data,@Path,@StartDate,@EndDate,@ChannelId,@IsMovie,@IsSeries,@EpisodeTitle,@IsRepeat,@CommunityRating,@CustomRating,@IndexNumber,@IsLocked,@Name,@OfficialRating,@MediaType,@Overview,@ParentIndexNumber,@PremiereDate,@ProductionYear,@ParentId,@Genres,@InheritedParentalRatingValue,@SortName,@ForcedSortName,@RunTimeTicks,@Size,@DateCreated,@DateModified,@PreferredMetadataLanguage,@PreferredMetadataCountryCode,@Width,@Height,@DateLastRefreshed,@DateLastSaved,@IsInMixedFolder,@LockedFields,@Studios,@Audio,@ExternalServiceId,@Tags,@IsFolder,@UnratedType,@TopParentId,@TrailerTypes,@CriticRating,@CleanName,@PresentationUniqueKey,@OriginalTitle,@PrimaryVersionId,@DateLastMediaAdded,@Album,@LUFS,@IsVirtualItem,@SeriesName,@UserDataKey,@SeasonName,@SeasonId,@SeriesId,@ExternalSeriesId,@Tagline,@ProviderIds,@Images,@ProductionLocations,@ExtraIds,@TotalBitrate,@ExtraType,@Artists,@AlbumArtists,@ExternalId,@SeriesPresentationUniqueKey,@ShowId,@OwnerId)"; private readonly IServerConfigurationManager _config; private readonly IServerApplicationHost _appHost; @@ -110,6 +112,7 @@ namespace Emby.Server.Implementations.Data "PrimaryVersionId", "DateLastMediaAdded", "Album", + "LUFS", "CriticRating", "IsVirtualItem", "SeriesName", @@ -318,13 +321,15 @@ namespace Emby.Server.Implementations.Data /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. + /// Instance of the interface. /// config is null. public SqliteItemRepository( IServerConfigurationManager config, IServerApplicationHost appHost, ILogger logger, ILocalizationManager localization, - IImageProcessor imageProcessor) + IImageProcessor imageProcessor, + IConfiguration configuration) : base(logger) { _config = config; @@ -336,10 +341,13 @@ namespace Emby.Server.Implementations.Data _jsonOptions = JsonDefaults.Options; DbFilePath = Path.Combine(_config.ApplicationPaths.DataPath, "library.db"); + + CacheSize = configuration.GetSqliteCacheSize(); + ReadConnectionsCount = Environment.ProcessorCount * 2; } /// - protected override int? CacheSize => 20000; + protected override int? CacheSize { get; } /// protected override TempStoreMode TempStore => TempStoreMode.Memory; @@ -347,10 +355,10 @@ namespace Emby.Server.Implementations.Data /// /// Opens the connection to the database. /// - /// The user data repository. - /// The user manager. - public void Initialize(SqliteUserDataRepository userDataRepo, IUserManager userManager) + public override void Initialize() { + base.Initialize(); + const string CreateMediaStreamsTableCommand = "create table if not exists mediastreams (ItemId GUID, StreamIndex INT, StreamType TEXT, Codec TEXT, Language TEXT, ChannelLayout TEXT, Profile TEXT, AspectRatio TEXT, Path TEXT, IsInterlaced BIT, BitRate INT NULL, Channels INT NULL, SampleRate INT NULL, IsDefault BIT, IsForced BIT, IsExternal BIT, Height INT NULL, Width INT NULL, AverageFrameRate FLOAT NULL, RealFrameRate FLOAT NULL, Level FLOAT NULL, PixelFormat TEXT, BitDepth INT NULL, IsAnamorphic BIT NULL, RefFrames INT NULL, CodecTag TEXT NULL, Comment TEXT NULL, NalLengthSize TEXT NULL, IsAvc BIT NULL, Title TEXT NULL, TimeBase TEXT NULL, CodecTimeBase TEXT NULL, ColorPrimaries TEXT NULL, ColorSpace TEXT NULL, ColorTransfer TEXT NULL, DvVersionMajor INT NULL, DvVersionMinor INT NULL, DvProfile INT NULL, DvLevel INT NULL, RpuPresentFlag INT NULL, ElPresentFlag INT NULL, BlPresentFlag INT NULL, DvBlSignalCompatibilityId INT NULL, IsHearingImpaired BIT NULL, PRIMARY KEY (ItemId, StreamIndex))"; @@ -488,6 +496,7 @@ namespace Emby.Server.Implementations.Data AddColumn(db, "TypedBaseItems", "PrimaryVersionId", "Text", existingColumnNames); AddColumn(db, "TypedBaseItems", "DateLastMediaAdded", "DATETIME", existingColumnNames); AddColumn(db, "TypedBaseItems", "Album", "Text", existingColumnNames); + AddColumn(db, "TypedBaseItems", "LUFS", "Float", existingColumnNames); AddColumn(db, "TypedBaseItems", "IsVirtualItem", "BIT", existingColumnNames); AddColumn(db, "TypedBaseItems", "SeriesName", "Text", existingColumnNames); AddColumn(db, "TypedBaseItems", "UserDataKey", "Text", existingColumnNames); @@ -551,8 +560,6 @@ namespace Emby.Server.Implementations.Data connection.RunQueries(postQueries); } - - userDataRepo.Initialize(userManager, WriteLock, WriteConnection); } public void SaveImages(BaseItem item) @@ -586,7 +593,7 @@ namespace Emby.Server.Implementations.Data /// /// or is null. /// - public void SaveItems(IEnumerable items, CancellationToken cancellationToken) + public void SaveItems(IReadOnlyList items, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(items); @@ -594,9 +601,11 @@ namespace Emby.Server.Implementations.Data CheckDisposed(); - var tuples = new List<(BaseItem, List, BaseItem, string, List)>(); - foreach (var item in items) + var itemsLen = items.Count; + var tuples = new ValueTuple, BaseItem, string, List>[itemsLen]; + for (int i = 0; i < itemsLen; i++) { + var item = items[i]; var ancestorIds = item.SupportsAncestors ? item.GetAncestorIds().Distinct().ToList() : null; @@ -606,7 +615,7 @@ namespace Emby.Server.Implementations.Data var userdataKey = item.GetUserDataKeys().FirstOrDefault(); var inheritedTags = item.GetInheritedTags(); - tuples.Add((item, ancestorIds, topParent, userdataKey, inheritedTags)); + tuples[i] = (item, ancestorIds, topParent, userdataKey, inheritedTags); } using (var connection = GetConnection()) @@ -622,14 +631,8 @@ namespace Emby.Server.Implementations.Data private void SaveItemsInTransaction(IDatabaseConnection db, IEnumerable<(BaseItem Item, List AncestorIds, BaseItem TopParent, string UserDataKey, List InheritedTags)> tuples) { - var statements = PrepareAll(db, new string[] - { - SaveItemCommandText, - "delete from AncestorIds where ItemId=@ItemId" - }); - - using (var saveItemStatement = statements[0]) - using (var deleteAncestorsStatement = statements[1]) + using (var saveItemStatement = PrepareStatement(db, SaveItemCommandText)) + using (var deleteAncestorsStatement = PrepareStatement(db, "delete from AncestorIds where ItemId=@ItemId")) { var requiresReset = false; foreach (var tuple in tuples) @@ -911,6 +914,7 @@ namespace Emby.Server.Implementations.Data } saveItemStatement.TryBind("@Album", item.Album); + saveItemStatement.TryBind("@LUFS", item.LUFS); saveItemStatement.TryBind("@IsVirtualItem", item.IsVirtualItem); if (item is IHasSeries hasSeriesName) @@ -1195,7 +1199,7 @@ namespace Emby.Server.Implementations.Data Path = RestorePath(path.ToString()) }; - if (long.TryParse(dateModified, NumberStyles.Any, CultureInfo.InvariantCulture, out var ticks) + if (long.TryParse(dateModified, CultureInfo.InvariantCulture, out var ticks) && ticks >= DateTime.MinValue.Ticks && ticks <= DateTime.MaxValue.Ticks) { @@ -1284,15 +1288,13 @@ namespace Emby.Server.Implementations.Data CheckDisposed(); using (var connection = GetConnection(true)) + using (var statement = PrepareStatement(connection, _retrieveItemColumnsSelectQuery)) { - using (var statement = PrepareStatement(connection, _retrieveItemColumnsSelectQuery)) - { - statement.TryBind("@guid", id); + statement.TryBind("@guid", id); - foreach (var row in statement.ExecuteQuery()) - { - return GetItem(row, new InternalItemsQuery()); - } + foreach (var row in statement.ExecuteQuery()) + { + return GetItem(row, new InternalItemsQuery()); } } @@ -1307,7 +1309,8 @@ namespace Emby.Server.Implementations.Data { return false; } - else if (type == typeof(UserRootFolder)) + + if (type == typeof(UserRootFolder)) { return false; } @@ -1317,55 +1320,68 @@ namespace Emby.Server.Implementations.Data { return false; } - else if (type == typeof(MusicArtist)) + + if (type == typeof(MusicArtist)) { return false; } - else if (type == typeof(Person)) + + if (type == typeof(Person)) { return false; } - else if (type == typeof(MusicGenre)) + + if (type == typeof(MusicGenre)) { return false; } - else if (type == typeof(Genre)) + + if (type == typeof(Genre)) { return false; } - else if (type == typeof(Studio)) + + if (type == typeof(Studio)) { return false; } - else if (type == typeof(PlaylistsFolder)) + + if (type == typeof(PlaylistsFolder)) { return false; } - else if (type == typeof(PhotoAlbum)) + + if (type == typeof(PhotoAlbum)) { return false; } - else if (type == typeof(Year)) + + if (type == typeof(Year)) { return false; } - else if (type == typeof(Book)) + + if (type == typeof(Book)) { return false; } - else if (type == typeof(LiveTvProgram)) + + if (type == typeof(LiveTvProgram)) { return false; } - else if (type == typeof(AudioBook)) + + if (type == typeof(AudioBook)) { return false; } - else if (type == typeof(Audio)) + + if (type == typeof(Audio)) { return false; } - else if (type == typeof(MusicAlbum)) + + if (type == typeof(MusicAlbum)) { return false; } @@ -1749,6 +1765,11 @@ namespace Emby.Server.Implementations.Data item.Album = album; } + if (reader.TryGetSingle(index++, out var lUFS)) + { + item.LUFS = lUFS; + } + if (reader.TryGetSingle(index++, out var criticRating)) { item.CriticRating = criticRating; @@ -1956,22 +1977,19 @@ namespace Emby.Server.Implementations.Data { CheckDisposed(); + var chapters = new List(); using (var connection = GetConnection(true)) + using (var statement = PrepareStatement(connection, "select StartPositionTicks,Name,ImagePath,ImageDateModified from " + ChaptersTableName + " where ItemId = @ItemId order by ChapterIndex asc")) { - var chapters = new List(); + statement.TryBind("@ItemId", item.Id); - using (var statement = PrepareStatement(connection, "select StartPositionTicks,Name,ImagePath,ImageDateModified from " + ChaptersTableName + " where ItemId = @ItemId order by ChapterIndex asc")) + foreach (var row in statement.ExecuteQuery()) { - statement.TryBind("@ItemId", item.Id); - - foreach (var row in statement.ExecuteQuery()) - { - chapters.Add(GetChapter(row, item)); - } + chapters.Add(GetChapter(row, item)); } - - return chapters; } + + return chapters; } /// @@ -1980,16 +1998,14 @@ namespace Emby.Server.Implementations.Data CheckDisposed(); using (var connection = GetConnection(true)) + using (var statement = PrepareStatement(connection, "select StartPositionTicks,Name,ImagePath,ImageDateModified from " + ChaptersTableName + " where ItemId = @ItemId and ChapterIndex=@ChapterIndex")) { - using (var statement = PrepareStatement(connection, "select StartPositionTicks,Name,ImagePath,ImageDateModified from " + ChaptersTableName + " where ItemId = @ItemId and ChapterIndex=@ChapterIndex")) - { - statement.TryBind("@ItemId", item.Id); - statement.TryBind("@ChapterIndex", index); + statement.TryBind("@ItemId", item.Id); + statement.TryBind("@ChapterIndex", index); - foreach (var row in statement.ExecuteQuery()) - { - return GetChapter(row, item); - } + foreach (var row in statement.ExecuteQuery()) + { + return GetChapter(row, item); } } @@ -2376,7 +2392,7 @@ namespace Emby.Server.Implementations.Data else { builder.Append( - @"(SELECT CASE WHEN InheritedParentalRatingValue=0 + @"(SELECT CASE WHEN COALESCE(InheritedParentalRatingValue, 0)=0 THEN 0 ELSE 10.0 / (1.0 + ABS(InheritedParentalRatingValue - @InheritedParentalRatingValue)) END)"); @@ -2390,6 +2406,7 @@ namespace Emby.Server.Implementations.Data // genres, tags, studios, person, year? builder.Append("+ (Select count(1) * 10 from ItemValues where ItemId=Guid and CleanValue in (select CleanValue from ItemValues where ItemId=@SimilarItemId))"); + builder.Append("+ (Select count(1) * 10 from People where ItemId=Guid and Name in (select Name from People where ItemId=@SimilarItemId))"); if (item is MusicArtist) { @@ -2841,13 +2858,10 @@ namespace Emby.Server.Implementations.Data connection.RunInTransaction( db => { - var itemQueryStatement = PrepareStatement(db, itemQuery); - var totalRecordCountQueryStatement = PrepareStatement(db, totalRecordCountQuery); - if (!isReturningZeroItems) { using (new QueryTimeLogger(Logger, itemQuery, "GetItems.ItemQuery")) - using (var statement = itemQueryStatement) + using (var statement = PrepareStatement(db, itemQuery)) { if (EnableJoinUserData(query)) { @@ -2882,7 +2896,7 @@ namespace Emby.Server.Implementations.Data if (query.EnableTotalRecordCount) { using (new QueryTimeLogger(Logger, totalRecordCountQuery, "GetItems.TotalRecordCount")) - using (var statement = totalRecordCountQueryStatement) + using (var statement = PrepareStatement(db, totalRecordCountQuery)) { if (EnableJoinUserData(query)) { @@ -3202,7 +3216,8 @@ namespace Emby.Server.Implementations.Data return IsAlphaNumeric(value); } - private List GetWhereClauses(InternalItemsQuery query, IStatement statement) +#nullable enable + private List GetWhereClauses(InternalItemsQuery query, IStatement? statement) { if (query.IsResumable ?? false) { @@ -3677,7 +3692,6 @@ namespace Emby.Server.Implementations.Data if (statement is not null) { nameContains = FixUnicodeChars(nameContains); - statement.TryBind("@NameContains", "%" + GetCleanValue(nameContains) + "%"); } } @@ -3803,13 +3817,8 @@ namespace Emby.Server.Implementations.Data foreach (var artistId in query.ArtistIds) { var paramName = "@ArtistIds" + index; - clauses.Add("(guid in (select itemid from ItemValues where CleanValue = (select CleanName from TypedBaseItems where guid=" + paramName + ") and Type<=1))"); - if (statement is not null) - { - statement.TryBind(paramName, artistId); - } - + statement?.TryBind(paramName, artistId); index++; } @@ -3824,13 +3833,8 @@ namespace Emby.Server.Implementations.Data foreach (var artistId in query.AlbumArtistIds) { var paramName = "@ArtistIds" + index; - clauses.Add("(guid in (select itemid from ItemValues where CleanValue = (select CleanName from TypedBaseItems where guid=" + paramName + ") and Type=1))"); - if (statement is not null) - { - statement.TryBind(paramName, artistId); - } - + statement?.TryBind(paramName, artistId); index++; } @@ -3845,13 +3849,8 @@ namespace Emby.Server.Implementations.Data foreach (var artistId in query.ContributingArtistIds) { var paramName = "@ArtistIds" + index; - clauses.Add("((select CleanName from TypedBaseItems where guid=" + paramName + ") in (select CleanValue from ItemValues where ItemId=Guid and Type=0) AND (select CleanName from TypedBaseItems where guid=" + paramName + ") not in (select CleanValue from ItemValues where ItemId=Guid and Type=1))"); - if (statement is not null) - { - statement.TryBind(paramName, artistId); - } - + statement?.TryBind(paramName, artistId); index++; } @@ -3866,13 +3865,8 @@ namespace Emby.Server.Implementations.Data foreach (var albumId in query.AlbumIds) { var paramName = "@AlbumIds" + index; - clauses.Add("Album in (select Name from typedbaseitems where guid=" + paramName + ")"); - if (statement is not null) - { - statement.TryBind(paramName, albumId); - } - + statement?.TryBind(paramName, albumId); index++; } @@ -3887,13 +3881,8 @@ namespace Emby.Server.Implementations.Data foreach (var artistId in query.ExcludeArtistIds) { var paramName = "@ExcludeArtistId" + index; - clauses.Add("(guid not in (select itemid from ItemValues where CleanValue = (select CleanName from TypedBaseItems where guid=" + paramName + ") and Type<=1))"); - if (statement is not null) - { - statement.TryBind(paramName, artistId); - } - + statement?.TryBind(paramName, artistId); index++; } @@ -3908,13 +3897,8 @@ namespace Emby.Server.Implementations.Data foreach (var genreId in query.GenreIds) { var paramName = "@GenreId" + index; - clauses.Add("(guid in (select itemid from ItemValues where CleanValue = (select CleanName from TypedBaseItems where guid=" + paramName + ") and Type=2))"); - if (statement is not null) - { - statement.TryBind(paramName, genreId); - } - + statement?.TryBind(paramName, genreId); index++; } @@ -3929,11 +3913,7 @@ namespace Emby.Server.Implementations.Data foreach (var item in query.Genres) { clauses.Add("@Genre" + index + " in (select CleanValue from ItemValues where ItemId=Guid and Type=2)"); - if (statement is not null) - { - statement.TryBind("@Genre" + index, GetCleanValue(item)); - } - + statement?.TryBind("@Genre" + index, GetCleanValue(item)); index++; } @@ -3948,11 +3928,7 @@ namespace Emby.Server.Implementations.Data foreach (var item in tags) { clauses.Add("@Tag" + index + " in (select CleanValue from ItemValues where ItemId=Guid and Type=4)"); - if (statement is not null) - { - statement.TryBind("@Tag" + index, GetCleanValue(item)); - } - + statement?.TryBind("@Tag" + index, GetCleanValue(item)); index++; } @@ -3967,11 +3943,7 @@ namespace Emby.Server.Implementations.Data foreach (var item in excludeTags) { clauses.Add("@ExcludeTag" + index + " not in (select CleanValue from ItemValues where ItemId=Guid and Type=4)"); - if (statement is not null) - { - statement.TryBind("@ExcludeTag" + index, GetCleanValue(item)); - } - + statement?.TryBind("@ExcludeTag" + index, GetCleanValue(item)); index++; } @@ -3986,14 +3958,8 @@ namespace Emby.Server.Implementations.Data foreach (var studioId in query.StudioIds) { var paramName = "@StudioId" + index; - clauses.Add("(guid in (select itemid from ItemValues where CleanValue = (select CleanName from TypedBaseItems where guid=" + paramName + ") and Type=3))"); - - if (statement is not null) - { - statement.TryBind(paramName, studioId); - } - + statement?.TryBind(paramName, studioId); index++; } @@ -4008,11 +3974,7 @@ namespace Emby.Server.Implementations.Data foreach (var item in query.OfficialRatings) { clauses.Add("OfficialRating=@OfficialRating" + index); - if (statement is not null) - { - statement.TryBind("@OfficialRating" + index, item); - } - + statement?.TryBind("@OfficialRating" + index, item); index++; } @@ -4020,34 +3982,96 @@ 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) + { + ratingClauseBuilder.Append(" AND InheritedParentalRatingValue <= @MaxParentalRating"); + statement?.TryBind("@MaxParentalRating", query.MaxParentalRating.Value); } } - - if (query.MaxParentalRating.HasValue) + 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.MinParentalRating.HasValue) + { + ratingClauseBuilder.Append("InheritedParentalRatingValue >= @MinParentalRating"); + statement?.TryBind("@MinParentalRating", query.MinParentalRating.Value); + } + + if (query.MaxParentalRating.HasValue) + { + if (query.MinParentalRating.HasValue) + { + ratingClauseBuilder.Append(" AND "); + } + + ratingClauseBuilder.Append("InheritedParentalRatingValue <= @MaxParentalRating"); + statement?.TryBind("@MaxParentalRating", query.MaxParentalRating.Value); + } + + if (query.MinParentalRating.HasValue || query.MaxParentalRating.HasValue) + { + ratingClauseBuilder.Append(")"); + } + + if (!(query.MinParentalRating.HasValue || query.MaxParentalRating.HasValue)) + { + ratingClauseBuilder.Append(" OR InheritedParentalRatingValue not null"); } } - - if (query.HasParentalRating.HasValue) + else if (query.MinParentalRating.HasValue) { - if (query.HasParentalRating.Value) + ratingClauseBuilder.Append("InheritedParentalRatingValue is null OR (InheritedParentalRatingValue >= @MinParentalRating"); + statement?.TryBind("@MinParentalRating", query.MinParentalRating.Value); + + if (query.MaxParentalRating.HasValue) { - whereClauses.Add("InheritedParentalRatingValue > 0"); - } - else - { - whereClauses.Add("InheritedParentalRatingValue = 0"); + ratingClauseBuilder.Append(" AND InheritedParentalRatingValue <= @MaxParentalRating"); + statement?.TryBind("@MaxParentalRating", query.MaxParentalRating.Value); } + + ratingClauseBuilder.Append(")"); + } + else if (query.MaxParentalRating.HasValue) + { + ratingClauseBuilder.Append("InheritedParentalRatingValue is null OR InheritedParentalRatingValue <= @MaxParentalRating"); + statement?.TryBind("@MaxParentalRating", query.MaxParentalRating.Value); + } + else if (!query.HasParentalRating ?? false) + { + ratingClauseBuilder.Append("InheritedParentalRatingValue is null"); + } + + var ratingClauseString = ratingClauseBuilder.ToString(); + if (!string.Equals(ratingClauseString, "(", StringComparison.OrdinalIgnoreCase)) + { + whereClauses.Add(ratingClauseString + ")"); } if (query.HasOfficialRating.HasValue) @@ -4089,37 +4113,25 @@ namespace Emby.Server.Implementations.Data if (!string.IsNullOrWhiteSpace(query.HasNoAudioTrackWithLanguage)) { whereClauses.Add("((select language from MediaStreams where MediaStreams.ItemId=A.Guid and MediaStreams.StreamType='Audio' and MediaStreams.Language=@HasNoAudioTrackWithLanguage limit 1) is null)"); - if (statement is not null) - { - statement.TryBind("@HasNoAudioTrackWithLanguage", query.HasNoAudioTrackWithLanguage); - } + statement?.TryBind("@HasNoAudioTrackWithLanguage", query.HasNoAudioTrackWithLanguage); } if (!string.IsNullOrWhiteSpace(query.HasNoInternalSubtitleTrackWithLanguage)) { whereClauses.Add("((select language from MediaStreams where MediaStreams.ItemId=A.Guid and MediaStreams.StreamType='Subtitle' and MediaStreams.IsExternal=0 and MediaStreams.Language=@HasNoInternalSubtitleTrackWithLanguage limit 1) is null)"); - if (statement is not null) - { - statement.TryBind("@HasNoInternalSubtitleTrackWithLanguage", query.HasNoInternalSubtitleTrackWithLanguage); - } + statement?.TryBind("@HasNoInternalSubtitleTrackWithLanguage", query.HasNoInternalSubtitleTrackWithLanguage); } if (!string.IsNullOrWhiteSpace(query.HasNoExternalSubtitleTrackWithLanguage)) { whereClauses.Add("((select language from MediaStreams where MediaStreams.ItemId=A.Guid and MediaStreams.StreamType='Subtitle' and MediaStreams.IsExternal=1 and MediaStreams.Language=@HasNoExternalSubtitleTrackWithLanguage limit 1) is null)"); - if (statement is not null) - { - statement.TryBind("@HasNoExternalSubtitleTrackWithLanguage", query.HasNoExternalSubtitleTrackWithLanguage); - } + statement?.TryBind("@HasNoExternalSubtitleTrackWithLanguage", query.HasNoExternalSubtitleTrackWithLanguage); } if (!string.IsNullOrWhiteSpace(query.HasNoSubtitleTrackWithLanguage)) { whereClauses.Add("((select language from MediaStreams where MediaStreams.ItemId=A.Guid and MediaStreams.StreamType='Subtitle' and MediaStreams.Language=@HasNoSubtitleTrackWithLanguage limit 1) is null)"); - if (statement is not null) - { - statement.TryBind("@HasNoSubtitleTrackWithLanguage", query.HasNoSubtitleTrackWithLanguage); - } + statement?.TryBind("@HasNoSubtitleTrackWithLanguage", query.HasNoSubtitleTrackWithLanguage); } if (query.HasSubtitles.HasValue) @@ -4169,15 +4181,11 @@ namespace Emby.Server.Implementations.Data if (query.Years.Length == 1) { whereClauses.Add("ProductionYear=@Years"); - if (statement is not null) - { - statement.TryBind("@Years", query.Years[0].ToString(CultureInfo.InvariantCulture)); - } + statement?.TryBind("@Years", query.Years[0].ToString(CultureInfo.InvariantCulture)); } else if (query.Years.Length > 1) { var val = string.Join(',', query.Years); - whereClauses.Add("ProductionYear in (" + val + ")"); } @@ -4185,10 +4193,7 @@ namespace Emby.Server.Implementations.Data if (isVirtualItem.HasValue) { whereClauses.Add("IsVirtualItem=@IsVirtualItem"); - if (statement is not null) - { - statement.TryBind("@IsVirtualItem", isVirtualItem.Value); - } + statement?.TryBind("@IsVirtualItem", isVirtualItem.Value); } if (query.IsSpecialSeason.HasValue) @@ -4219,31 +4224,22 @@ namespace Emby.Server.Implementations.Data if (queryMediaTypes.Length == 1) { whereClauses.Add("MediaType=@MediaTypes"); - if (statement is not null) - { - statement.TryBind("@MediaTypes", queryMediaTypes[0]); - } + statement?.TryBind("@MediaTypes", queryMediaTypes[0]); } else if (queryMediaTypes.Length > 1) { var val = string.Join(',', queryMediaTypes.Select(i => "'" + i + "'")); - whereClauses.Add("MediaType in (" + val + ")"); } if (query.ItemIds.Length > 0) { var includeIds = new List(); - var index = 0; foreach (var id in query.ItemIds) { includeIds.Add("Guid = @IncludeId" + index); - if (statement is not null) - { - statement.TryBind("@IncludeId" + index, id); - } - + statement?.TryBind("@IncludeId" + index, id); index++; } @@ -4253,16 +4249,11 @@ namespace Emby.Server.Implementations.Data if (query.ExcludeItemIds.Length > 0) { var excludeIds = new List(); - var index = 0; foreach (var id in query.ExcludeItemIds) { excludeIds.Add("Guid <> @ExcludeId" + index); - if (statement is not null) - { - statement.TryBind("@ExcludeId" + index, id); - } - + statement?.TryBind("@ExcludeId" + index, id); index++; } @@ -4283,11 +4274,7 @@ namespace Emby.Server.Implementations.Data var paramName = "@ExcludeProviderId" + index; excludeIds.Add("(ProviderIds is null or ProviderIds not like " + paramName + ")"); - if (statement is not null) - { - statement.TryBind(paramName, "%" + pair.Key + "=" + pair.Value + "%"); - } - + statement?.TryBind(paramName, "%" + pair.Key + "=" + pair.Value + "%"); index++; break; @@ -4312,7 +4299,7 @@ namespace Emby.Server.Implementations.Data } // TODO this seems to be an idea for a better schema where ProviderIds are their own table - // buut this is not implemented + // but this is not implemented // hasProviderIds.Add("(COALESCE((select value from ProviderIds where ItemId=Guid and Name = '" + pair.Key + "'), '') <> " + paramName + ")"); // TODO this is a really BAD way to do it since the pair: @@ -4326,11 +4313,7 @@ namespace Emby.Server.Implementations.Data hasProviderIds.Add("ProviderIds like " + paramName); // this replaces the placeholder with a value, here: %key=val% - if (statement is not null) - { - statement.TryBind(paramName, "%" + pair.Key + "=" + pair.Value + "%"); - } - + statement?.TryBind(paramName, "%" + pair.Key + "=" + pair.Value + "%"); index++; break; @@ -4407,11 +4390,7 @@ namespace Emby.Server.Implementations.Data if (query.AncestorIds.Length == 1) { whereClauses.Add("Guid in (select itemId from AncestorIds where AncestorId=@AncestorId)"); - - if (statement is not null) - { - statement.TryBind("@AncestorId", query.AncestorIds[0]); - } + statement?.TryBind("@AncestorId", query.AncestorIds[0]); } if (query.AncestorIds.Length > 1) @@ -4424,39 +4403,13 @@ namespace Emby.Server.Implementations.Data { var inClause = "select guid from TypedBaseItems where PresentationUniqueKey=@AncestorWithPresentationUniqueKey"; whereClauses.Add(string.Format(CultureInfo.InvariantCulture, "Guid in (select itemId from AncestorIds where AncestorId in ({0}))", inClause)); - if (statement is not null) - { - statement.TryBind("@AncestorWithPresentationUniqueKey", query.AncestorWithPresentationUniqueKey); - } + statement?.TryBind("@AncestorWithPresentationUniqueKey", query.AncestorWithPresentationUniqueKey); } if (!string.IsNullOrWhiteSpace(query.SeriesPresentationUniqueKey)) { whereClauses.Add("SeriesPresentationUniqueKey=@SeriesPresentationUniqueKey"); - - if (statement is not null) - { - statement.TryBind("@SeriesPresentationUniqueKey", query.SeriesPresentationUniqueKey); - } - } - - if (query.BlockUnratedItems.Length == 1) - { - whereClauses.Add("(InheritedParentalRatingValue > 0 or UnratedType <> @UnratedType)"); - if (statement is not null) - { - statement.TryBind("@UnratedType", query.BlockUnratedItems[0].ToString()); - } - } - - if (query.BlockUnratedItems.Length > 1) - { - var inClause = string.Join(',', query.BlockUnratedItems.Select(i => "'" + i.ToString() + "'")); - whereClauses.Add( - string.Format( - CultureInfo.InvariantCulture, - "(InheritedParentalRatingValue > 0 or UnratedType not in ({0}))", - inClause)); + statement?.TryBind("@SeriesPresentationUniqueKey", query.SeriesPresentationUniqueKey); } if (query.ExcludeInheritedTags.Length > 0) @@ -4477,6 +4430,24 @@ namespace Emby.Server.Implementations.Data } } + if (query.IncludeInheritedTags.Length > 0) + { + var paramName = "@IncludeInheritedTags"; + if (statement is null) + { + int index = 0; + string includedTags = string.Join(',', query.IncludeInheritedTags.Select(_ => paramName + index++)); + whereClauses.Add("((select CleanValue from ItemValues where ItemId=Guid and Type=6 and cleanvalue in (" + includedTags + ")) is not null)"); + } + else + { + for (int index = 0; index < query.IncludeInheritedTags.Length; index++) + { + statement.TryBind(paramName + index, GetCleanValue(query.IncludeInheritedTags[index])); + } + } + } + if (query.SeriesStatuses.Length > 0) { var statuses = new List(); @@ -4587,6 +4558,7 @@ namespace Emby.Server.Implementations.Data return whereClauses; } +#nullable disable /// /// Formats a where clause for the specified provider. @@ -4793,22 +4765,20 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type commandText.Append(" LIMIT ").Append(query.Limit); } + var list = new List(); using (var connection = GetConnection(true)) + using (var statement = PrepareStatement(connection, commandText.ToString())) { - var list = new List(); - using (var statement = PrepareStatement(connection, commandText.ToString())) + // Run this again to bind the params + GetPeopleWhereClauses(query, statement); + + foreach (var row in statement.ExecuteQuery()) { - // Run this again to bind the params - GetPeopleWhereClauses(query, statement); - - foreach (var row in statement.ExecuteQuery()) - { - list.Add(row.GetString(0)); - } + list.Add(row.GetString(0)); } - - return list; } + + return list; } public List GetPeople(InternalPeopleQuery query) @@ -4833,23 +4803,20 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type commandText += " LIMIT " + query.Limit; } + var list = new List(); using (var connection = GetConnection(true)) + using (var statement = PrepareStatement(connection, commandText)) { - var list = new List(); + // Run this again to bind the params + GetPeopleWhereClauses(query, statement); - using (var statement = PrepareStatement(connection, commandText)) + foreach (var row in statement.ExecuteQuery()) { - // Run this again to bind the params - GetPeopleWhereClauses(query, statement); - - foreach (var row in statement.ExecuteQuery()) - { - list.Add(GetPerson(row)); - } + list.Add(GetPerson(row)); } - - return list; } + + return list; } private List GetPeopleWhereClauses(InternalPeopleQuery query, IStatement statement) @@ -5440,6 +5407,9 @@ AND Type = @InternalPersonType)"); list.AddRange(inheritedTags.Select(i => (6, i))); + // Remove all invalid values. + list.RemoveAll(i => string.IsNullOrEmpty(i.Item2)); + return list; } @@ -5577,7 +5547,7 @@ AND Type = @InternalPersonType)"); statement.TryBind("@Name" + index, person.Name); statement.TryBind("@Role" + index, person.Role); - statement.TryBind("@PersonType" + index, person.Type); + statement.TryBind("@PersonType" + index, person.Type.ToString()); statement.TryBind("@SortOrder" + index, person.SortOrder); statement.TryBind("@ListOrder" + index, listIndex); @@ -5606,9 +5576,10 @@ AND Type = @InternalPersonType)"); item.Role = role; } - if (reader.TryGetString(3, out var type)) + if (reader.TryGetString(3, out var type) + && Enum.TryParse(type, true, out PersonKind personKind)) { - item.Type = type; + item.Type = personKind; } if (reader.TryGetInt32(4, out var sortOrder)) diff --git a/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs b/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs index 5f2c3c9dcc..a1e217ad14 100644 --- a/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs +++ b/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs @@ -7,7 +7,7 @@ using System.Collections.Generic; using System.IO; using System.Threading; using Jellyfin.Data.Entities; -using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Persistence; @@ -18,33 +18,32 @@ namespace Emby.Server.Implementations.Data { public class SqliteUserDataRepository : BaseSqliteRepository, IUserDataRepository { + private readonly IUserManager _userManager; + public SqliteUserDataRepository( ILogger logger, - IApplicationPaths appPaths) + IServerConfigurationManager config, + IUserManager userManager) : base(logger) { - DbFilePath = Path.Combine(appPaths.DataPath, "library.db"); + _userManager = userManager; + + DbFilePath = Path.Combine(config.ApplicationPaths.DataPath, "library.db"); } /// /// Opens the connection to the database. /// - /// The user manager. - /// The lock to use for database IO. - /// The connection to use for database IO. - public void Initialize(IUserManager userManager, SemaphoreSlim dbLock, SQLiteDatabaseConnection dbConnection) + public override void Initialize() { - WriteLock.Dispose(); - WriteLock = dbLock; - WriteConnection?.Dispose(); - WriteConnection = dbConnection; + base.Initialize(); using (var connection = GetConnection()) { var userDatasTableExists = TableExists(connection, "UserDatas"); var userDataTableExists = TableExists(connection, "userdata"); - var users = userDatasTableExists ? null : userManager.Users; + var users = userDatasTableExists ? null : _userManager.Users; connection.RunInTransaction( db => @@ -371,20 +370,5 @@ namespace Emby.Server.Implementations.Data return userData; } - -#pragma warning disable CA2215 - /// - /// - /// There is nothing to dispose here since and - /// are managed by . - /// See . - /// - protected override void Dispose(bool dispose) - { - // The write lock and connection for the item repository are shared with the user data repository - // since they point to the same database. The item repo has responsibility for disposing these two objects, - // so the user data repo should not attempt to dispose them as well - } -#pragma warning restore CA2215 } } diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index 5103b1fbfb..7a6ed2cb80 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -7,7 +7,6 @@ using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; -using Jellyfin.Api.Helpers; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Extensions; @@ -83,22 +82,23 @@ namespace Emby.Server.Implementations.Dto /// public IReadOnlyList GetBaseItemDtos(IReadOnlyList items, DtoOptions options, User user = null, BaseItem owner = null) { - var returnItems = new BaseItemDto[items.Count]; - var programTuples = new List<(BaseItem, BaseItemDto)>(); - var channelTuples = new List<(BaseItemDto, LiveTvChannel)>(); + var accessibleItems = user is null ? items : items.Where(x => x.IsVisible(user)).ToList(); + var returnItems = new BaseItemDto[accessibleItems.Count]; + List<(BaseItem, BaseItemDto)> programTuples = null; + List<(BaseItemDto, LiveTvChannel)> channelTuples = null; - for (int index = 0; index < items.Count; index++) + for (int index = 0; index < accessibleItems.Count; index++) { - var item = items[index]; + var item = accessibleItems[index]; var dto = GetBaseItemDtoInternal(item, options, user, owner); if (item is LiveTvChannel tvChannel) { - channelTuples.Add((dto, tvChannel)); + (channelTuples ??= new()).Add((dto, tvChannel)); } else if (item is LiveTvProgram) { - programTuples.Add((item, dto)); + (programTuples ??= new()).Add((item, dto)); } if (item is IItemByName byName) @@ -121,12 +121,12 @@ namespace Emby.Server.Implementations.Dto returnItems[index] = dto; } - if (programTuples.Count > 0) + if (programTuples is not null) { LivetvManager.AddInfoToProgramDto(programTuples, options.Fields, user).GetAwaiter().GetResult(); } - if (channelTuples.Count > 0) + if (channelTuples is not null) { LivetvManager.AddChannelInfo(channelTuples, options, user); } @@ -522,32 +522,32 @@ namespace Emby.Server.Implementations.Dto var people = _libraryManager.GetPeople(item).OrderBy(i => i.SortOrder ?? int.MaxValue) .ThenBy(i => { - if (i.IsType(PersonType.Actor)) + if (i.IsType(PersonKind.Actor)) { return 0; } - if (i.IsType(PersonType.GuestStar)) + if (i.IsType(PersonKind.GuestStar)) { return 1; } - if (i.IsType(PersonType.Director)) + if (i.IsType(PersonKind.Director)) { return 2; } - if (i.IsType(PersonType.Writer)) + if (i.IsType(PersonKind.Writer)) { return 3; } - if (i.IsType(PersonType.Producer)) + if (i.IsType(PersonKind.Producer)) { return 4; } - if (i.IsType(PersonType.Composer)) + if (i.IsType(PersonKind.Composer)) { return 4; } @@ -571,9 +571,7 @@ namespace Emby.Server.Implementations.Dto return null; } }).Where(i => i is not null) - .Where(i => user is null ? - true : - i.IsVisible(user)) + .Where(i => user is null || i.IsVisible(user)) .DistinctBy(x => x.Name, StringComparer.OrdinalIgnoreCase) .ToDictionary(i => i.Name, StringComparer.OrdinalIgnoreCase); @@ -908,6 +906,7 @@ namespace Emby.Server.Implementations.Dto // Add audio info if (item is Audio audio) { + dto.LUFS = audio.LUFS; dto.Album = audio.Album; if (audio.ExtraType.HasValue) { diff --git a/Emby.Server.Implementations/Emby.Server.Implementations.csproj b/Emby.Server.Implementations/Emby.Server.Implementations.csproj index 1b5c879beb..b8655c7600 100644 --- a/Emby.Server.Implementations/Emby.Server.Implementations.csproj +++ b/Emby.Server.Implementations/Emby.Server.Implementations.csproj @@ -22,17 +22,17 @@ - - - - - - - - - - - + + + + + + + + + + + @@ -53,13 +53,13 @@ - + all runtime; build; native; contentfiles; analyzers - - - + + + diff --git a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs index 05d0a9b794..be36bbd2c1 100644 --- a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs +++ b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs @@ -12,6 +12,7 @@ using System.Threading.Tasks; using Jellyfin.Data.Entities; using Jellyfin.Data.Events; using MediaBrowser.Controller.Channels; +using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; @@ -26,12 +27,8 @@ namespace Emby.Server.Implementations.EntryPoints { public class LibraryChangedNotifier : IServerEntryPoint { - /// - /// The library update duration. - /// - private const int LibraryUpdateDuration = 30000; - private readonly ILibraryManager _libraryManager; + private readonly IServerConfigurationManager _configurationManager; private readonly IProviderManager _providerManager; private readonly ISessionManager _sessionManager; private readonly IUserManager _userManager; @@ -51,12 +48,14 @@ namespace Emby.Server.Implementations.EntryPoints public LibraryChangedNotifier( ILibraryManager libraryManager, + IServerConfigurationManager configurationManager, ISessionManager sessionManager, IUserManager userManager, ILogger logger, IProviderManager providerManager) { _libraryManager = libraryManager; + _configurationManager = configurationManager; _sessionManager = sessionManager; _userManager = userManager; _logger = logger; @@ -196,12 +195,12 @@ namespace Emby.Server.Implementations.EntryPoints LibraryUpdateTimer = new Timer( LibraryUpdateTimerCallback, null, - LibraryUpdateDuration, - Timeout.Infinite); + TimeSpan.FromSeconds(_configurationManager.Configuration.LibraryUpdateDuration), + Timeout.InfiniteTimeSpan); } else { - LibraryUpdateTimer.Change(LibraryUpdateDuration, Timeout.Infinite); + LibraryUpdateTimer.Change(TimeSpan.FromSeconds(_configurationManager.Configuration.LibraryUpdateDuration), Timeout.InfiniteTimeSpan); } if (e.Item.GetParent() is Folder parent) @@ -229,11 +228,11 @@ namespace Emby.Server.Implementations.EntryPoints { if (LibraryUpdateTimer is null) { - LibraryUpdateTimer = new Timer(LibraryUpdateTimerCallback, null, LibraryUpdateDuration, Timeout.Infinite); + LibraryUpdateTimer = new Timer(LibraryUpdateTimerCallback, null, TimeSpan.FromSeconds(_configurationManager.Configuration.LibraryUpdateDuration), Timeout.InfiniteTimeSpan); } else { - LibraryUpdateTimer.Change(LibraryUpdateDuration, Timeout.Infinite); + LibraryUpdateTimer.Change(TimeSpan.FromSeconds(_configurationManager.Configuration.LibraryUpdateDuration), Timeout.InfiniteTimeSpan); } _itemsUpdated.Add(e.Item); @@ -256,11 +255,11 @@ namespace Emby.Server.Implementations.EntryPoints { if (LibraryUpdateTimer is null) { - LibraryUpdateTimer = new Timer(LibraryUpdateTimerCallback, null, LibraryUpdateDuration, Timeout.Infinite); + LibraryUpdateTimer = new Timer(LibraryUpdateTimerCallback, null, TimeSpan.FromSeconds(_configurationManager.Configuration.LibraryUpdateDuration), Timeout.InfiniteTimeSpan); } else { - LibraryUpdateTimer.Change(LibraryUpdateDuration, Timeout.Infinite); + LibraryUpdateTimer.Change(TimeSpan.FromSeconds(_configurationManager.Configuration.LibraryUpdateDuration), Timeout.InfiniteTimeSpan); } if (e.Parent is Folder parent) @@ -276,25 +275,31 @@ namespace Emby.Server.Implementations.EntryPoints /// Libraries the update timer callback. /// /// The state. - private void LibraryUpdateTimerCallback(object state) + private async void LibraryUpdateTimerCallback(object state) { + List foldersAddedTo; + List foldersRemovedFrom; + List itemsUpdated; + List itemsAdded; + List itemsRemoved; lock (_libraryChangedSyncLock) { // Remove dupes in case some were saved multiple times - var foldersAddedTo = _foldersAddedTo + foldersAddedTo = _foldersAddedTo .DistinctBy(x => x.Id) .ToList(); - var foldersRemovedFrom = _foldersRemovedFrom + foldersRemovedFrom = _foldersRemovedFrom .DistinctBy(x => x.Id) .ToList(); - var itemsUpdated = _itemsUpdated + itemsUpdated = _itemsUpdated .Where(i => !_itemsAdded.Contains(i)) .DistinctBy(x => x.Id) .ToList(); - SendChangeNotifications(_itemsAdded.ToList(), itemsUpdated, _itemsRemoved.ToList(), foldersAddedTo, foldersRemovedFrom, CancellationToken.None).GetAwaiter().GetResult(); + itemsAdded = _itemsAdded.ToList(); + itemsRemoved = _itemsRemoved.ToList(); if (LibraryUpdateTimer is not null) { @@ -308,6 +313,8 @@ namespace Emby.Server.Implementations.EntryPoints _foldersAddedTo.Clear(); _foldersRemovedFrom.Clear(); } + + await SendChangeNotifications(itemsAdded, itemsUpdated, itemsRemoved, foldersAddedTo, foldersRemovedFrom, CancellationToken.None).ConfigureAwait(false); } /// diff --git a/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs b/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs index e724618b3a..d32759017d 100644 --- a/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs +++ b/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs @@ -87,29 +87,30 @@ namespace Emby.Server.Implementations.EntryPoints } } - private void UpdateTimerCallback(object? state) + private async void UpdateTimerCallback(object? state) { + List>> 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>> changes, CancellationToken cancellationToken) { - foreach (var pair in changes) + foreach ((var key, var value) in changes) { - await SendNotifications(pair.Key, pair.Value, cancellationToken).ConfigureAwait(false); + await SendNotifications(key, value, cancellationToken).ConfigureAwait(false); } } diff --git a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs index b1a99853ad..af79c18c4e 100644 --- a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs +++ b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs @@ -9,7 +9,7 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Extensions.Json; using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Net; +using MediaBrowser.Controller.Net.WebSocketMessages; using MediaBrowser.Model.Session; using Microsoft.Extensions.Logging; @@ -85,6 +85,18 @@ namespace Emby.Server.Implementations.HttpServer /// The state. public WebSocketState State => _socket.State; + /// + /// Sends a message asynchronously. + /// + /// The message. + /// The cancellation token. + /// Task. + public Task SendAsync(WebSocketMessage message, CancellationToken cancellationToken) + { + var json = JsonSerializer.SerializeToUtf8Bytes(message, _jsonOptions); + return _socket.SendAsync(json, WebSocketMessageType.Text, true, cancellationToken); + } + /// /// Sends a message asynchronously. /// @@ -224,7 +236,7 @@ namespace Emby.Server.Implementations.HttpServer { LastKeepAliveDate = DateTime.UtcNow; return SendAsync( - new WebSocketMessage + new OutboundWebSocketMessage { MessageId = Guid.NewGuid(), MessageType = SessionMessageType.KeepAlive diff --git a/Emby.Server.Implementations/IO/ExtendedFileSystemInfo.cs b/Emby.Server.Implementations/IO/ExtendedFileSystemInfo.cs deleted file mode 100644 index 545d73e05f..0000000000 --- a/Emby.Server.Implementations/IO/ExtendedFileSystemInfo.cs +++ /dev/null @@ -1,13 +0,0 @@ -#pragma warning disable CS1591 - -namespace Emby.Server.Implementations.IO -{ - public class ExtendedFileSystemInfo - { - public bool IsHidden { get; set; } - - public bool IsReadOnly { get; set; } - - public bool Exists { get; set; } - } -} diff --git a/Emby.Server.Implementations/IO/ManagedFileSystem.cs b/Emby.Server.Implementations/IO/ManagedFileSystem.cs index 7b8c79e8a9..60ab668cde 100644 --- a/Emby.Server.Implementations/IO/ManagedFileSystem.cs +++ b/Emby.Server.Implementations/IO/ManagedFileSystem.cs @@ -267,25 +267,6 @@ namespace Emby.Server.Implementations.IO return result; } - private static ExtendedFileSystemInfo GetExtendedFileSystemInfo(string path) - { - var result = new ExtendedFileSystemInfo(); - - var info = new FileInfo(path); - - if (info.Exists) - { - result.Exists = true; - - var attributes = info.Attributes; - - result.IsHidden = (attributes & FileAttributes.Hidden) == FileAttributes.Hidden; - result.IsReadOnly = (attributes & FileAttributes.ReadOnly) == FileAttributes.ReadOnly; - } - - return result; - } - /// /// Takes a filename and removes invalid characters. /// @@ -405,19 +386,18 @@ namespace Emby.Server.Implementations.IO return; } - var info = GetExtendedFileSystemInfo(path); + var info = new FileInfo(path); - if (info.Exists && info.IsHidden != isHidden) + if (info.Exists && + ((info.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden) != isHidden) { if (isHidden) { - File.SetAttributes(path, File.GetAttributes(path) | FileAttributes.Hidden); + File.SetAttributes(path, info.Attributes | FileAttributes.Hidden); } else { - var attributes = File.GetAttributes(path); - attributes = RemoveAttribute(attributes, FileAttributes.Hidden); - File.SetAttributes(path, attributes); + File.SetAttributes(path, info.Attributes & ~FileAttributes.Hidden); } } } @@ -430,19 +410,20 @@ namespace Emby.Server.Implementations.IO return; } - var info = GetExtendedFileSystemInfo(path); + var info = new FileInfo(path); if (!info.Exists) { return; } - if (info.IsReadOnly == readOnly && info.IsHidden == isHidden) + if (((info.Attributes & FileAttributes.ReadOnly) == FileAttributes.ReadOnly) == readOnly + && ((info.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden) == isHidden) { return; } - var attributes = File.GetAttributes(path); + var attributes = info.Attributes; if (readOnly) { @@ -450,7 +431,7 @@ namespace Emby.Server.Implementations.IO } else { - attributes = RemoveAttribute(attributes, FileAttributes.ReadOnly); + attributes &= ~FileAttributes.ReadOnly; } if (isHidden) @@ -459,17 +440,12 @@ namespace Emby.Server.Implementations.IO } else { - attributes = RemoveAttribute(attributes, FileAttributes.Hidden); + attributes &= ~FileAttributes.Hidden; } File.SetAttributes(path, attributes); } - private static FileAttributes RemoveAttribute(FileAttributes attributes, FileAttributes attributesToRemove) - { - return attributes & ~attributesToRemove; - } - /// /// Swaps the files. /// diff --git a/Emby.Server.Implementations/Images/BaseFolderImageProvider.cs b/Emby.Server.Implementations/Images/BaseFolderImageProvider.cs index 6fc7f1ac3a..84c21931c3 100644 --- a/Emby.Server.Implementations/Images/BaseFolderImageProvider.cs +++ b/Emby.Server.Implementations/Images/BaseFolderImageProvider.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System.Collections.Generic; diff --git a/Emby.Server.Implementations/Images/FolderImageProvider.cs b/Emby.Server.Implementations/Images/FolderImageProvider.cs index 4376bd356c..90f7568a90 100644 --- a/Emby.Server.Implementations/Images/FolderImageProvider.cs +++ b/Emby.Server.Implementations/Images/FolderImageProvider.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using MediaBrowser.Common.Configuration; diff --git a/Emby.Server.Implementations/Images/GenreImageProvider.cs b/Emby.Server.Implementations/Images/GenreImageProvider.cs index 968bf5fa33..c9b41f8193 100644 --- a/Emby.Server.Implementations/Images/GenreImageProvider.cs +++ b/Emby.Server.Implementations/Images/GenreImageProvider.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System.Collections.Generic; diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index a3c66dc798..ea45bf0ba0 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -113,6 +113,7 @@ namespace Emby.Server.Implementations.Library /// The image processor. /// The memory cache. /// The naming options. + /// The directory service. 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(); @@ -146,7 +148,7 @@ namespace Emby.Server.Implementations.Library _memoryCache = memoryCache; _namingOptions = namingOptions; - _extraResolver = new ExtraResolver(loggerFactory.CreateLogger(), namingOptions); + _extraResolver = new ExtraResolver(loggerFactory.CreateLogger(), 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(); + ? ((Folder)item).GetRecursiveChildren(false) + : Enumerable.Empty(); 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 { parent }); + SetTopParentIdsOrAncestors(query, new[] { parent }); } } @@ -1262,7 +1264,14 @@ namespace Emby.Server.Implementations.Library AddUserToQuery(query, query.User, allowExternalContent); } - return _itemRepository.GetItemList(query); + var itemList = _itemRepository.GetItemList(query); + var user = query.User; + if (user is not null) + { + return itemList.Where(i => i.IsVisible(user)).ToList(); + } + + return itemList; } public List GetItemList(InternalItemsQuery query) @@ -1277,7 +1286,7 @@ namespace Emby.Server.Implementations.Library var parent = GetItemById(query.ParentId); if (parent is not null) { - SetTopParentIdsOrAncestors(query, new List { parent }); + SetTopParentIdsOrAncestors(query, new[] { parent }); } } @@ -1435,7 +1444,7 @@ namespace Emby.Server.Implementations.Library var parent = GetItemById(query.ParentId); if (parent is not null) { - SetTopParentIdsOrAncestors(query, new List { parent }); + SetTopParentIdsOrAncestors(query, new[] { parent }); } } @@ -1455,7 +1464,7 @@ namespace Emby.Server.Implementations.Library _itemRepository.GetItemList(query)); } - private void SetTopParentIdsOrAncestors(InternalItemsQuery query, List parents) + private void SetTopParentIdsOrAncestors(InternalItemsQuery query, IReadOnlyCollection parents) { if (parents.All(i => i is ICollectionFolder || i is UserView)) { @@ -1501,6 +1510,12 @@ namespace Emby.Server.Implementations.Library }); query.TopParentIds = userViews.SelectMany(i => GetTopParentIdsForQuery(i, user)).ToArray(); + + // Prevent searching in all libraries due to empty filter + if (query.TopParentIds.Length == 0) + { + query.TopParentIds = new[] { Guid.NewGuid() }; + } } } @@ -1602,7 +1617,7 @@ namespace Emby.Server.Implementations.Library { _logger.LogError(ex, "Error getting intros"); - return new List(); + return Enumerable.Empty(); } } @@ -1877,7 +1892,7 @@ namespace Emby.Server.Implementations.Library catch (Exception ex) { _logger.LogError(ex, "Cannot get image dimensions for {ImagePath}", image.Path); - size = new ImageDimensions(0, 0); + size = default; image.Width = 0; image.Height = 0; } @@ -2741,9 +2756,7 @@ namespace Emby.Server.Implementations.Library } }) .Where(i => i is not null) - .Where(i => query.User is null ? - true : - i.IsVisible(query.User)) + .Where(i => query.User is null || i.IsVisible(query.User)) .ToList(); } @@ -2876,7 +2889,7 @@ namespace Emby.Server.Implementations.Library private async Task SavePeopleMetadataAsync(IEnumerable people, CancellationToken cancellationToken) { - var personsToSave = new List(); + List personsToSave = null; foreach (var person in people) { @@ -2918,12 +2931,12 @@ namespace Emby.Server.Implementations.Library if (saveEntity) { - personsToSave.Add(personEntity); + (personsToSave ??= new()).Add(personEntity); await RunMetadataSavers(personEntity, itemUpdateType).ConfigureAwait(false); } } - if (personsToSave.Count > 0) + if (personsToSave is not null) { CreateItems(personsToSave, null, CancellationToken.None); } @@ -3085,22 +3098,19 @@ namespace Emby.Server.Implementations.Library throw new ArgumentNullException(nameof(path)); } - var removeList = new List(); + List removeList = null; foreach (var contentType in _configurationManager.Configuration.ContentTypes) { - if (string.IsNullOrWhiteSpace(contentType.Name)) - { - removeList.Add(contentType); - } - else if (_fileSystem.AreEqual(path, contentType.Name) + if (string.IsNullOrWhiteSpace(contentType.Name) + || _fileSystem.AreEqual(path, contentType.Name) || _fileSystem.ContainsSubPath(path, contentType.Name)) { - removeList.Add(contentType); + (removeList ??= new()).Add(contentType); } } - if (removeList.Count > 0) + if (removeList is not null) { _configurationManager.Configuration.ContentTypes = _configurationManager.Configuration.ContentTypes .Except(removeList) diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs index eadfa5dfe9..c9a26a30f5 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -154,8 +154,8 @@ namespace Emby.Server.Implementations.Library // If file is strm or main media stream is missing, force a metadata refresh with remote probing if (allowMediaProbe && mediaSources[0].Type != MediaSourceType.Placeholder && (item.Path.EndsWith(".strm", StringComparison.OrdinalIgnoreCase) - || (item.MediaType == MediaType.Video && !mediaSources[0].MediaStreams.Any(i => i.Type == MediaStreamType.Video)) - || (item.MediaType == MediaType.Audio && !mediaSources[0].MediaStreams.Any(i => i.Type == MediaStreamType.Audio)))) + || (item.MediaType == MediaType.Video && mediaSources[0].MediaStreams.All(i => i.Type != MediaStreamType.Video)) + || (item.MediaType == MediaType.Audio && mediaSources[0].MediaStreams.All(i => i.Type != MediaStreamType.Audio)))) { await item.RefreshMetadata( new MetadataRefreshOptions(_directoryService) diff --git a/Emby.Server.Implementations/Library/PathExtensions.cs b/Emby.Server.Implementations/Library/PathExtensions.cs index 64e7d54466..c4b6b37561 100644 --- a/Emby.Server.Implementations/Library/PathExtensions.cs +++ b/Emby.Server.Implementations/Library/PathExtensions.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.IO; using MediaBrowser.Common.Providers; namespace Emby.Server.Implementations.Library @@ -86,24 +87,8 @@ namespace Emby.Server.Implementations.Library return false; } - char oldDirectorySeparatorChar; - char newDirectorySeparatorChar; - // True normalization is still not possible https://github.com/dotnet/runtime/issues/2162 - // The reasoning behind this is that a forward slash likely means it's a Linux path and - // so the whole path should be normalized to use / and vice versa for Windows (although Windows doesn't care much). - if (newSubPath.Contains('/', StringComparison.Ordinal)) - { - oldDirectorySeparatorChar = '\\'; - newDirectorySeparatorChar = '/'; - } - else - { - oldDirectorySeparatorChar = '/'; - newDirectorySeparatorChar = '\\'; - } - - path = path.Replace(oldDirectorySeparatorChar, newDirectorySeparatorChar); - subPath = subPath.Replace(oldDirectorySeparatorChar, newDirectorySeparatorChar); + subPath = subPath.NormalizePath(out var newDirectorySeparatorChar); + path = path.NormalizePath(newDirectorySeparatorChar); // We have to ensure that the sub path ends with a directory separator otherwise we'll get weird results // when the sub path matches a similar but in-complete subpath @@ -127,5 +112,82 @@ namespace Emby.Server.Implementations.Library return true; } + + /// + /// Retrieves the full resolved path and normalizes path separators to the . + /// + /// The path to canonicalize. + /// The fully expanded, normalized path. + public static string Canonicalize(this string path) + { + return Path.GetFullPath(path).NormalizePath(); + } + + /// + /// Normalizes the path's directory separator character to the currently defined . + /// + /// The path to normalize. + /// The normalized path string or if the input path is null or empty. + [return: NotNullIfNotNull(nameof(path))] + public static string? NormalizePath(this string? path) + { + return path.NormalizePath(Path.DirectorySeparatorChar); + } + + /// + /// Normalizes the path's directory separator character. + /// + /// The path to normalize. + /// The separator character the path now uses or . + /// The normalized path string or if the input path is null or empty. + [return: NotNullIfNotNull(nameof(path))] + public static string? NormalizePath(this string? path, out char separator) + { + if (string.IsNullOrEmpty(path)) + { + separator = default; + return path; + } + + var newSeparator = '\\'; + + // True normalization is still not possible https://github.com/dotnet/runtime/issues/2162 + // The reasoning behind this is that a forward slash likely means it's a Linux path and + // so the whole path should be normalized to use / and vice versa for Windows (although Windows doesn't care much). + if (path.Contains('/', StringComparison.Ordinal)) + { + newSeparator = '/'; + } + + separator = newSeparator; + + return path.NormalizePath(newSeparator); + } + + /// + /// Normalizes the path's directory separator character to the specified character. + /// + /// The path to normalize. + /// The replacement directory separator character. Must be a valid directory separator. + /// The normalized path. + /// Thrown if the new separator character is not a directory separator. + [return: NotNullIfNotNull(nameof(path))] + public static string? NormalizePath(this string? path, char newSeparator) + { + const char Bs = '\\'; + const char Fs = '/'; + + if (!(newSeparator == Bs || newSeparator == Fs)) + { + throw new ArgumentException("The character must be a directory separator."); + } + + if (string.IsNullOrEmpty(path)) + { + return path; + } + + return newSeparator == Bs ? path.Replace(Fs, newSeparator) : path.Replace(Bs, newSeparator); + } } } diff --git a/Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs index 06621700aa..a74f824752 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs @@ -158,7 +158,6 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio private MultiItemResolverResult ResolveMultipleAudio(Folder parent, IEnumerable fileSystemEntries, bool parseName) { var files = new List(); - var items = new List(); var leftOver = new List(); // 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() }; var isInMixedFolder = resolverResult.Count > 1 || (parent is not null && parent.IsTopParent); @@ -193,7 +192,8 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio continue; } - if (resolvedItem.Files.Count == 0) + // Until multi-part books are handled letting files stack hides them from browsing in the client + if (resolvedItem.Files.Count == 0 || resolvedItem.Extras.Count > 0 || resolvedItem.AlternateVersions.Count > 0) { continue; } diff --git a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs index a922e36855..bbc70701cb 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs @@ -25,16 +25,19 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio { private readonly ILogger _logger; private readonly NamingOptions _namingOptions; + private readonly IDirectoryService _directoryService; /// /// Initializes a new instance of the class. /// /// The logger. /// The naming options. - public MusicAlbumResolver(ILogger logger, NamingOptions namingOptions) + /// The directory service. + public MusicAlbumResolver(ILogger logger, NamingOptions namingOptions, IDirectoryService directoryService) { _logger = logger; _namingOptions = namingOptions; + _directoryService = directoryService; } /// @@ -109,7 +112,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio } // If args contains music it's a music album - if (ContainsMusic(args.FileSystemChildren, true, args.DirectoryService)) + if (ContainsMusic(args.FileSystemChildren, true, _directoryService)) { return true; } diff --git a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs index 2538c2b5b4..c858dc53d9 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using Emby.Naming.Common; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Resolvers; using MediaBrowser.Model.Entities; using Microsoft.Extensions.Logging; @@ -18,19 +19,23 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio public class MusicArtistResolver : ItemResolver { private readonly ILogger _logger; - private NamingOptions _namingOptions; + private readonly NamingOptions _namingOptions; + private readonly IDirectoryService _directoryService; /// /// Initializes a new instance of the class. /// /// Instance of the interface. /// The . + /// The directory service. public MusicArtistResolver( ILogger logger, - NamingOptions namingOptions) + NamingOptions namingOptions, + IDirectoryService directoryService) { _logger = logger; _namingOptions = namingOptions; + _directoryService = directoryService; } /// @@ -78,9 +83,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio return null; } - var directoryService = args.DirectoryService; - - var albumResolver = new MusicAlbumResolver(_logger, _namingOptions); + var albumResolver = new MusicAlbumResolver(_logger, _namingOptions, _directoryService); var directories = args.FileSystemChildren.Where(i => i.IsDirectory); @@ -97,7 +100,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio } // If we contain a music album assume we are an artist folder - if (albumResolver.IsMusicAlbum(fileSystemInfo.FullName, directoryService)) + if (albumResolver.IsMusicAlbum(fileSystemInfo.FullName, _directoryService)) { // Stop once we see a music album state.Stop(); diff --git a/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs b/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs index e8615e7db7..381796d0e3 100644 --- a/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs @@ -25,14 +25,17 @@ namespace Emby.Server.Implementations.Library.Resolvers { private readonly ILogger _logger; - protected BaseVideoResolver(ILogger logger, NamingOptions namingOptions) + protected BaseVideoResolver(ILogger logger, NamingOptions namingOptions, IDirectoryService directoryService) { _logger = logger; NamingOptions = namingOptions; + DirectoryService = directoryService; } protected NamingOptions NamingOptions { get; } + protected IDirectoryService DirectoryService { get; } + /// /// Resolves the specified args. /// @@ -65,13 +68,26 @@ namespace Emby.Server.Implementations.Library.Resolvers var filename = child.Name; if (child.IsDirectory) { - if (IsDvdDirectory(child.FullName, filename, args.DirectoryService)) + if (IsDvdDirectory(child.FullName, filename, DirectoryService)) { - videoType = VideoType.Dvd; + var videoTmp = new TVideoType + { + Path = args.Path, + VideoType = VideoType.Dvd + }; + Set3DFormat(videoTmp); + return videoTmp; } - else if (IsBluRayDirectory(filename)) + + if (IsBluRayDirectory(filename)) { - videoType = VideoType.BluRay; + var videoTmp = new TVideoType + { + Path = args.Path, + VideoType = VideoType.BluRay + }; + Set3DFormat(videoTmp); + return videoTmp; } } else if (IsDvdFile(filename)) diff --git a/Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs b/Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs index 30c52e19d3..b4791b9456 100644 --- a/Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs @@ -4,6 +4,8 @@ using System.IO; using Emby.Naming.Common; using Emby.Naming.Video; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Resolvers; using MediaBrowser.Model.Entities; using Microsoft.Extensions.Logging; @@ -14,7 +16,7 @@ namespace Emby.Server.Implementations.Library.Resolvers /// /// Resolves a Path into a Video or Video subclass. /// - internal class ExtraResolver + internal class ExtraResolver : BaseVideoResolver /// The logger. /// An instance of . - public ExtraResolver(ILogger logger, NamingOptions namingOptions) + /// The directory service. + public ExtraResolver(ILogger logger, NamingOptions namingOptions, IDirectoryService directoryService) + : base(logger, namingOptions, directoryService) { _namingOptions = namingOptions; - _trailerResolvers = new IItemResolver[] { new GenericVideoResolver(logger, namingOptions) }; - _videoResolvers = new IItemResolver[] { new GenericVideoResolver /// The video. + /// The library options for the video. /// true if [is eligible for chapter image extraction] [the specified video]; otherwise, false. - private bool IsEligibleForChapterImageExtraction(Video video) + private bool IsEligibleForChapterImageExtraction(Video video, LibraryOptions libraryOptions) { if (video.IsPlaceHolder) { return false; } - var libraryOptions = _libraryManager.GetLibraryOptions(video); - if (libraryOptions is not null) - { - if (!libraryOptions.EnableChapterImageExtraction) - { - return false; - } - } - else + if (libraryOptions is null || !libraryOptions.EnableChapterImageExtraction) { return false; } @@ -99,7 +93,9 @@ namespace Emby.Server.Implementations.MediaEncoder public async Task RefreshChapterImages(Video video, IDirectoryService directoryService, IReadOnlyList chapters, bool extractImages, bool saveChapters, CancellationToken cancellationToken) { - if (!IsEligibleForChapterImageExtraction(video)) + var libraryOptions = _libraryManager.GetLibraryOptions(video); + + if (!IsEligibleForChapterImageExtraction(video, libraryOptions)) { extractImages = false; } @@ -179,6 +175,12 @@ namespace Emby.Server.Implementations.MediaEncoder chapter.ImageDateModified = _fileSystem.GetLastWriteTimeUtc(path); changesMade = true; } + else if (libraryOptions?.EnableChapterImageExtraction != true) + { + // We have an image for the current chapter but the user has disabled chapter image extraction -> delete this chapter's image + chapter.ImagePath = null; + changesMade = true; + } } if (saveChapters && changesMade) diff --git a/Emby.Server.Implementations/Playlists/PlaylistManager.cs b/Emby.Server.Implementations/Playlists/PlaylistManager.cs index 2717c392b2..702f8d45bc 100644 --- a/Emby.Server.Implementations/Playlists/PlaylistManager.cs +++ b/Emby.Server.Implementations/Playlists/PlaylistManager.cs @@ -67,9 +67,8 @@ namespace Emby.Server.Implementations.Playlists public async Task CreatePlaylist(PlaylistCreationRequest options) { var name = options.Name; - var folderName = _fileSystem.GetValidFilename(name); - var parentFolder = GetPlaylistsFolder(Guid.Empty); + var parentFolder = GetPlaylistsFolder(options.UserId); if (parentFolder is null) { throw new ArgumentException(nameof(parentFolder)); @@ -80,7 +79,6 @@ namespace Emby.Server.Implementations.Playlists foreach (var itemId in options.ItemIdList) { var item = _libraryManager.GetItemById(itemId); - if (item is null) { throw new ArgumentException("No item exists with the supplied Id"); @@ -121,7 +119,6 @@ namespace Emby.Server.Implementations.Playlists } var user = _userManager.GetUserById(options.UserId); - var path = Path.Combine(parentFolder.Path, folderName); path = GetTargetPath(path); @@ -130,25 +127,15 @@ namespace Emby.Server.Implementations.Playlists try { Directory.CreateDirectory(path); - var playlist = new Playlist { Name = name, Path = path, - Shares = new[] - { - new Share - { - UserId = options.UserId.Equals(default) - ? null - : options.UserId.ToString("N", CultureInfo.InvariantCulture), - CanEdit = true - } - } + OwnerUserId = options.UserId, + Shares = options.Shares ?? Array.Empty() }; playlist.SetMediaType(options.MediaType); - parentFolder.AddChild(playlist); await playlist.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(_fileSystem)) { ForceSave = true }, CancellationToken.None) @@ -334,7 +321,8 @@ namespace Emby.Server.Implementations.Playlists } } - private void SavePlaylistFile(Playlist item) + /// + public void SavePlaylistFile(Playlist item) { // this is probably best done as a metadata provider // saving a file over itself will require some work to prevent this from happening when not needed @@ -537,5 +525,40 @@ namespace Emby.Server.Implementations.Playlists return _libraryManager.RootFolder.Children.OfType().FirstOrDefault(i => string.Equals(i.GetType().Name, TypeName, StringComparison.Ordinal)) ?? _libraryManager.GetUserRootFolder().Children.OfType().FirstOrDefault(i => string.Equals(i.GetType().Name, TypeName, StringComparison.Ordinal)); } + + /// + public async Task RemovePlaylistsAsync(Guid userId) + { + var playlists = GetPlaylists(userId); + foreach (var playlist in playlists) + { + // Update owner if shared + var rankedShares = playlist.Shares.OrderByDescending(x => x.CanEdit).ToArray(); + if (rankedShares.Length > 0 && Guid.TryParse(rankedShares[0].UserId, out var guid)) + { + playlist.OwnerUserId = guid; + playlist.Shares = rankedShares.Skip(1).ToArray(); + await playlist.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + + if (playlist.IsFile) + { + SavePlaylistFile(playlist); + } + } + else if (!playlist.OpenAccess) + { + // Remove playlist if not shared + _libraryManager.DeleteItem( + playlist, + new DeleteOptions + { + DeleteFileLocation = false, + DeleteFromExternalProvider = false + }, + playlist.GetParent(), + false); + } + } + } } } diff --git a/Emby.Server.Implementations/Playlists/PlaylistsFolder.cs b/Emby.Server.Implementations/Playlists/PlaylistsFolder.cs index e2f2e436f2..d67caa52dc 100644 --- a/Emby.Server.Implementations/Playlists/PlaylistsFolder.cs +++ b/Emby.Server.Implementations/Playlists/PlaylistsFolder.cs @@ -27,11 +27,6 @@ namespace Emby.Server.Implementations.Playlists [JsonIgnore] public override string CollectionType => MediaBrowser.Model.Entities.CollectionType.Playlists; - public override bool IsVisible(User user) - { - return base.IsVisible(user) && GetChildren(user, true).Any(); - } - protected override IEnumerable GetEligibleChildrenForRecursiveChildren(User user) { return base.GetEligibleChildrenForRecursiveChildren(user).OfType(); @@ -47,8 +42,7 @@ namespace Emby.Server.Implementations.Playlists query.Recursive = true; query.IncludeItemTypes = new[] { BaseItemKind.Playlist }; - query.Parent = null; - return LibraryManager.GetItemsResult(query); + return QueryWithPostFiltering2(query); } public override string GetClientTypeName() diff --git a/Emby.Server.Implementations/Plugins/PluginManager.cs b/Emby.Server.Implementations/Plugins/PluginManager.cs index f2212f4dcb..48584ae0cb 100644 --- a/Emby.Server.Implementations/Plugins/PluginManager.cs +++ b/Emby.Server.Implementations/Plugins/PluginManager.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Data; using System.Globalization; using System.IO; using System.Linq; @@ -9,6 +10,8 @@ using System.Runtime.Loader; using System.Text; using System.Text.Json; using System.Threading.Tasks; +using Emby.Server.Implementations.Library; +using Jellyfin.Extensions; using Jellyfin.Extensions.Json; using Jellyfin.Extensions.Json.Converters; using MediaBrowser.Common; @@ -29,6 +32,8 @@ namespace Emby.Server.Implementations.Plugins /// public class PluginManager : IPluginManager { + private const string MetafileName = "meta.json"; + private readonly string _pluginsPath; private readonly Version _appVersion; private readonly List _assemblyLoadContexts; @@ -44,7 +49,7 @@ namespace Emby.Server.Implementations.Plugins /// /// Initializes a new instance of the class. /// - /// The . + /// The . /// The . /// The . /// The plugin path. @@ -123,41 +128,64 @@ namespace Emby.Server.Implementations.Plugins continue; } + var assemblyLoadContext = new PluginLoadContext(plugin.Path); + _assemblyLoadContexts.Add(assemblyLoadContext); + + var assemblies = new List(plugin.DllFiles.Count); + var loadedAll = true; + foreach (var file in plugin.DllFiles) { - Assembly assembly; try { - var assemblyLoadContext = new PluginLoadContext(file); - _assemblyLoadContexts.Add(assemblyLoadContext); - - assembly = assemblyLoadContext.LoadFromAssemblyPath(file); - - // Load all required types to verify that the plugin will load - assembly.GetTypes(); + assemblies.Add(assemblyLoadContext.LoadFromAssemblyPath(file)); } catch (FileLoadException ex) { - _logger.LogError(ex, "Failed to load assembly {Path}. Disabling plugin.", file); + _logger.LogError(ex, "Failed to load assembly {Path}. Disabling plugin", file); ChangePluginState(plugin, PluginStatus.Malfunctioned); - continue; - } - 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); - ChangePluginState(plugin, PluginStatus.NotSupported); - 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); + _logger.LogError(ex, "Failed to load assembly {Path}. Unknown exception was thrown. Disabling plugin", file); ChangePluginState(plugin, PluginStatus.Malfunctioned); - continue; + 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", assembly.Location); + ChangePluginState(plugin, PluginStatus.NotSupported); + 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", assembly.Location); + ChangePluginState(plugin, PluginStatus.Malfunctioned); + break; } - _logger.LogInformation("Loaded assembly {Assembly} from {Path}", assembly.FullName, file); + _logger.LogInformation("Loaded assembly {Assembly} from {Path}", assembly.FullName, assembly.Location); yield return assembly; } } @@ -281,7 +309,7 @@ namespace Emby.Server.Implementations.Plugins // If no version is given, return the current instance. var plugins = _plugins.Where(p => p.Id.Equals(id)).ToList(); - plugin = plugins.FirstOrDefault(p => p.Instance is not null) ?? plugins.OrderByDescending(p => p.Version).FirstOrDefault(); + plugin = plugins.FirstOrDefault(p => p.Instance is not null) ?? plugins.MaxBy(p => p.Version); } else { @@ -348,7 +376,7 @@ namespace Emby.Server.Implementations.Plugins try { var data = JsonSerializer.Serialize(manifest, _jsonOptions); - File.WriteAllText(Path.Combine(path, "meta.json"), data); + File.WriteAllText(Path.Combine(path, MetafileName), data); return true; } catch (ArgumentException e) @@ -359,7 +387,7 @@ namespace Emby.Server.Implementations.Plugins } /// - public async Task GenerateManifest(PackageInfo packageInfo, Version version, string path, PluginStatus status) + public async Task PopulateManifest(PackageInfo packageInfo, Version version, string path, PluginStatus status) { var versionInfo = packageInfo.Versions.First(v => v.Version == version.ToString()); var imagePath = string.Empty; @@ -404,9 +432,71 @@ namespace Emby.Server.Implementations.Plugins ImagePath = imagePath }; + if (!await ReconcileManifest(manifest, path)) + { + // An error occurred during reconciliation and saving could be undesirable. + return false; + } + return SaveManifest(manifest, path); } + /// + /// Reconciles the manifest against any properties that exist locally in a pre-packaged meta.json found at the path. + /// If no file is found, no reconciliation occurs. + /// + /// The to reconcile against. + /// The plugin path. + /// The reconciled . + private async Task ReconcileManifest(PluginManifest manifest, string path) + { + try + { + var metafile = Path.Combine(path, MetafileName); + if (!File.Exists(metafile)) + { + _logger.LogInformation("No local manifest exists for plugin {Plugin}. Skipping manifest reconciliation.", manifest.Name); + return true; + } + + using var metaStream = File.OpenRead(metafile); + var localManifest = await JsonSerializer.DeserializeAsync(metaStream, _jsonOptions); + localManifest ??= new PluginManifest(); + + if (!Equals(localManifest.Id, manifest.Id)) + { + _logger.LogError("The manifest ID {LocalUUID} did not match the package info ID {PackageUUID}.", localManifest.Id, manifest.Id); + manifest.Status = PluginStatus.Malfunctioned; + } + + if (localManifest.Version != manifest.Version) + { + // Package information provides the version and is the source of truth. Pre-packages meta.json is assumed to be a mistake in this regard. + _logger.LogWarning("The version of the local manifest was {LocalVersion}, but {PackageVersion} was expected. The value will be replaced.", localManifest.Version, manifest.Version); + } + + // Explicitly mapping properties instead of using reflection is preferred here. + manifest.Category = string.IsNullOrEmpty(localManifest.Category) ? manifest.Category : localManifest.Category; + manifest.AutoUpdate = localManifest.AutoUpdate; // Preserve whatever is local. Package info does not have this property. + manifest.Changelog = string.IsNullOrEmpty(localManifest.Changelog) ? manifest.Changelog : localManifest.Changelog; + manifest.Description = string.IsNullOrEmpty(localManifest.Description) ? manifest.Description : localManifest.Description; + manifest.Name = string.IsNullOrEmpty(localManifest.Name) ? manifest.Name : localManifest.Name; + manifest.Overview = string.IsNullOrEmpty(localManifest.Overview) ? manifest.Overview : localManifest.Overview; + manifest.Owner = string.IsNullOrEmpty(localManifest.Owner) ? manifest.Owner : localManifest.Owner; + manifest.TargetAbi = string.IsNullOrEmpty(localManifest.TargetAbi) ? manifest.TargetAbi : localManifest.TargetAbi; + manifest.Timestamp = localManifest.Timestamp.Equals(default) ? manifest.Timestamp : localManifest.Timestamp; + manifest.ImagePath = string.IsNullOrEmpty(localManifest.ImagePath) ? manifest.ImagePath : localManifest.ImagePath; + manifest.Assemblies = localManifest.Assemblies; + + return true; + } + catch (Exception e) + { + _logger.LogWarning(e, "Unable to reconcile plugin manifest due to an error. {Path}", path); + return false; + } + } + /// /// Changes a plugin's load status. /// @@ -571,7 +661,7 @@ namespace Emby.Server.Implementations.Plugins { Version? version; PluginManifest? manifest = null; - var metafile = Path.Combine(dir, "meta.json"); + var metafile = Path.Combine(dir, MetafileName); if (File.Exists(metafile)) { // Only path where this stays null is when File.ReadAllBytes throws an IOException @@ -665,7 +755,15 @@ namespace Emby.Server.Implementations.Plugins var entry = versions[x]; if (!string.Equals(lastName, entry.Name, StringComparison.OrdinalIgnoreCase)) { - entry.DllFiles = Directory.GetFiles(entry.Path, "*.dll", SearchOption.AllDirectories); + if (!TryGetPluginDlls(entry, out var allowedDlls)) + { + _logger.LogError("One or more assembly paths was invalid. Marking plugin {Plugin} as \"Malfunctioned\".", entry.Name); + ChangePluginState(entry, PluginStatus.Malfunctioned); + continue; + } + + entry.DllFiles = allowedDlls; + if (entry.IsEnabledAndSupported) { lastName = entry.Name; @@ -711,6 +809,68 @@ namespace Emby.Server.Implementations.Plugins return versions.Where(p => p.DllFiles.Count != 0); } + /// + /// Attempts to retrieve valid DLLs from the plugin path. This method will consider the assembly whitelist + /// from the manifest. + /// + /// + /// Loading DLLs from externally supplied paths introduces a path traversal risk. This method + /// uses a safelisting tactic of considering DLLs from the plugin directory and only using + /// the plugin's canonicalized assembly whitelist for comparison. See + /// for more details. + /// + /// The plugin. + /// The whitelisted DLLs. If the method returns , this will be empty. + /// + /// if all assemblies listed in the manifest were available in the plugin directory. + /// if any assemblies were invalid or missing from the plugin directory. + /// + /// If the is null. + private bool TryGetPluginDlls(LocalPlugin plugin, out IReadOnlyList whitelistedDlls) + { + ArgumentNullException.ThrowIfNull(nameof(plugin)); + + IReadOnlyList pluginDlls = Directory.GetFiles(plugin.Path, "*.dll", SearchOption.AllDirectories); + + whitelistedDlls = Array.Empty(); + if (pluginDlls.Count > 0 && plugin.Manifest.Assemblies.Count > 0) + { + _logger.LogInformation("Registering whitelisted assemblies for plugin \"{Plugin}\"...", plugin.Name); + + var canonicalizedPaths = new List(); + foreach (var path in plugin.Manifest.Assemblies) + { + var canonicalized = Path.Combine(plugin.Path, path).Canonicalize(); + + // Ensure we stay in the plugin directory. + if (!canonicalized.StartsWith(plugin.Path.NormalizePath(), StringComparison.Ordinal)) + { + _logger.LogError("Assembly path {Path} is not inside the plugin directory.", path); + return false; + } + + canonicalizedPaths.Add(canonicalized); + } + + var intersected = pluginDlls.Intersect(canonicalizedPaths).ToList(); + + if (intersected.Count != canonicalizedPaths.Count) + { + _logger.LogError("Plugin {Plugin} contained assembly paths that were not found in the directory.", plugin.Name); + return false; + } + + whitelistedDlls = intersected; + } + else + { + // No whitelist, default to loading all DLLs in plugin directory. + whitelistedDlls = pluginDlls; + } + + return true; + } + /// /// Changes the status of the other versions of the plugin to "Superceded". /// diff --git a/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs b/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs index ee9aa85699..1af2c96d2f 100644 --- a/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs +++ b/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs @@ -93,11 +93,8 @@ namespace Emby.Server.Implementations.ScheduledTasks public ScheduledTaskWorker(IScheduledTask scheduledTask, IApplicationPaths applicationPaths, ITaskManager taskManager, ILogger logger) { ArgumentNullException.ThrowIfNull(scheduledTask); - ArgumentNullException.ThrowIfNull(applicationPaths); - ArgumentNullException.ThrowIfNull(taskManager); - ArgumentNullException.ThrowIfNull(logger); ScheduledTask = scheduledTask; @@ -332,7 +329,7 @@ namespace Emby.Server.Implementations.ScheduledTasks return; } - _logger.LogInformation("{0} fired for task: {1}", trigger.GetType().Name, Name); + _logger.LogDebug("{0} fired for task: {1}", trigger.GetType().Name, Name); trigger.Stop(); @@ -378,7 +375,7 @@ namespace Emby.Server.Implementations.ScheduledTasks CurrentCancellationTokenSource = new CancellationTokenSource(); - _logger.LogInformation("Executing {0}", Name); + _logger.LogDebug("Executing {0}", Name); ((TaskManager)_taskManager).OnTaskExecuting(this); @@ -406,7 +403,7 @@ namespace Emby.Server.Implementations.ScheduledTasks } catch (Exception ex) { - _logger.LogError(ex, "Error"); + _logger.LogError(ex, "Error executing Scheduled Task"); failureException = ex; diff --git a/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs b/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs index 63f0beb105..42c30c959d 100644 --- a/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs +++ b/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System; @@ -43,9 +41,9 @@ namespace Emby.Server.Implementations.ScheduledTasks ScheduledTasks = Array.Empty(); } - public event EventHandler> TaskExecuting; + public event EventHandler>? TaskExecuting; - public event EventHandler TaskCompleted; + public event EventHandler? TaskCompleted; /// /// 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 /// private void ExecuteQueuedTasks() { - _logger.LogInformation("ExecuteQueuedTasks"); - - // Execute queued tasks lock (_taskQueue) { var list = new List>(); diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs index abc203618d..6ad6c4cbd6 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs @@ -100,7 +100,6 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks EnableImages = false }, SourceTypes = new SourceType[] { SourceType.Library }, - HasChapterImages = false, IsVirtualItem = false }) .OfType /// The x. /// DateTime. - private static DateTime GetDate(BaseItem x) + private static DateTime GetDate(BaseItem? x) { if (x is LiveTvProgram hasStartDate) { diff --git a/Emby.Server.Implementations/Sorting/StudioComparer.cs b/Emby.Server.Implementations/Sorting/StudioComparer.cs index 457c062714..89d10f3d23 100644 --- a/Emby.Server.Implementations/Sorting/StudioComparer.cs +++ b/Emby.Server.Implementations/Sorting/StudioComparer.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System; @@ -24,10 +22,9 @@ namespace Emby.Server.Implementations.Sorting /// The x. /// The y. /// System.Int32. - public int Compare(BaseItem x, BaseItem y) + public int Compare(BaseItem? x, BaseItem? y) { ArgumentNullException.ThrowIfNull(x); - ArgumentNullException.ThrowIfNull(y); return AlphanumericComparator.CompareValues(x.Studios.FirstOrDefault(), y.Studios.FirstOrDefault()); diff --git a/Emby.Server.Implementations/SyncPlay/Group.cs b/Emby.Server.Implementations/SyncPlay/Group.cs index 7d7ea58106..da8f949326 100644 --- a/Emby.Server.Implementations/SyncPlay/Group.cs +++ b/Emby.Server.Implementations/SyncPlay/Group.cs @@ -620,10 +620,8 @@ namespace Emby.Server.Implementations.SyncPlay RestartCurrentItem(); return true; } - else - { - return false; - } + + return false; } /// @@ -637,10 +635,8 @@ namespace Emby.Server.Implementations.SyncPlay RestartCurrentItem(); return true; } - else - { - return false; - } + + return false; } /// diff --git a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs index 63c4a15564..00c655634a 100644 --- a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs +++ b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs @@ -339,10 +339,8 @@ namespace Emby.Server.Implementations.SyncPlay { return sessionsCounter > 0; } - else - { - return false; - } + + return false; } /// diff --git a/Emby.Server.Implementations/TV/TVSeriesManager.cs b/Emby.Server.Implementations/TV/TVSeriesManager.cs index 967f90b55f..f0e173f0b1 100644 --- a/Emby.Server.Implementations/TV/TVSeriesManager.cs +++ b/Emby.Server.Implementations/TV/TVSeriesManager.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System; @@ -42,7 +40,7 @@ namespace Emby.Server.Implementations.TV throw new ArgumentException("User not found"); } - string presentationUniqueKey = null; + string? presentationUniqueKey = null; if (query.SeriesId.HasValue && !query.SeriesId.Value.Equals(default)) { if (_libraryManager.GetItemById(query.SeriesId.Value) is Series series) @@ -91,7 +89,7 @@ namespace Emby.Server.Implementations.TV throw new ArgumentException("User not found"); } - string presentationUniqueKey = null; + string? presentationUniqueKey = null; int? limit = null; if (request.SeriesId.HasValue && !request.SeriesId.Value.Equals(default)) { @@ -168,7 +166,7 @@ namespace Emby.Server.Implementations.TV return !anyFound && i.LastWatchedDate == DateTime.MinValue; }) .Select(i => i.GetEpisodeFunction()) - .Where(i => i is not null); + .Where(i => i is not null)!; } private static string GetUniqueSeriesKey(Episode episode) @@ -185,7 +183,7 @@ namespace Emby.Server.Implementations.TV /// Gets the next up. /// /// Task{Episode}. - private (DateTime LastWatchedDate, Func GetEpisodeFunction) GetNextUp(string seriesKey, User user, DtoOptions dtoOptions, bool rewatching) + private (DateTime LastWatchedDate, Func 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().FirstOrDefault(); - Episode GetEpisode() + Episode? GetEpisode() { var nextQuery = new InternalItemsQuery(user) { diff --git a/Emby.Server.Implementations/Updates/InstallationManager.cs b/Emby.Server.Implementations/Updates/InstallationManager.cs index 5e897833e0..6c198b6f99 100644 --- a/Emby.Server.Implementations/Updates/InstallationManager.cs +++ b/Emby.Server.Implementations/Updates/InstallationManager.cs @@ -183,7 +183,7 @@ namespace Emby.Server.Implementations.Updates var plugin = _pluginManager.GetPlugin(package.Id, version.VersionNumber); if (plugin is not null) { - await _pluginManager.GenerateManifest(package, version.VersionNumber, plugin.Path, plugin.Manifest.Status).ConfigureAwait(false); + await _pluginManager.PopulateManifest(package, version.VersionNumber, plugin.Path, plugin.Manifest.Status).ConfigureAwait(false); } // Remove versions with a target ABI greater then the current application version. @@ -555,7 +555,10 @@ namespace Emby.Server.Implementations.Updates stream.Position = 0; using var reader = new ZipArchive(stream); reader.ExtractToDirectory(targetDir, true); - await _pluginManager.GenerateManifest(package.PackageInfo, package.Version, targetDir, status).ConfigureAwait(false); + + // Ensure we create one or populate existing ones with missing data. + await _pluginManager.PopulateManifest(package.PackageInfo, package.Version, targetDir, status); + _pluginManager.ImportPluginFrom(targetDir); } diff --git a/Jellyfin.Api/Attributes/AcceptsFileAttribute.cs b/Jellyfin.Api/Attributes/AcceptsFileAttribute.cs index fbe68b6b97..a6c89bab88 100644 --- a/Jellyfin.Api/Attributes/AcceptsFileAttribute.cs +++ b/Jellyfin.Api/Attributes/AcceptsFileAttribute.cs @@ -2,29 +2,28 @@ using System; -namespace Jellyfin.Api.Attributes +namespace Jellyfin.Api.Attributes; + +/// +/// Internal produces image attribute. +/// +[AttributeUsage(AttributeTargets.Method)] +public class AcceptsFileAttribute : Attribute { + private readonly string[] _contentTypes; + /// - /// Internal produces image attribute. + /// Initializes a new instance of the class. /// - [AttributeUsage(AttributeTargets.Method)] - public class AcceptsFileAttribute : Attribute + /// Content types this endpoint produces. + public AcceptsFileAttribute(params string[] contentTypes) { - private readonly string[] _contentTypes; - - /// - /// Initializes a new instance of the class. - /// - /// Content types this endpoint produces. - public AcceptsFileAttribute(params string[] contentTypes) - { - _contentTypes = contentTypes; - } - - /// - /// Gets the configured content types. - /// - /// the configured content types. - public string[] ContentTypes => _contentTypes; + _contentTypes = contentTypes; } + + /// + /// Gets the configured content types. + /// + /// the configured content types. + public string[] ContentTypes => _contentTypes; } diff --git a/Jellyfin.Api/Attributes/AcceptsImageFileAttribute.cs b/Jellyfin.Api/Attributes/AcceptsImageFileAttribute.cs index 244a29da45..57433202e1 100644 --- a/Jellyfin.Api/Attributes/AcceptsImageFileAttribute.cs +++ b/Jellyfin.Api/Attributes/AcceptsImageFileAttribute.cs @@ -1,18 +1,17 @@ -namespace Jellyfin.Api.Attributes -{ - /// - /// Produces file attribute of "image/*". - /// - public sealed class AcceptsImageFileAttribute : AcceptsFileAttribute - { - private const string ContentType = "image/*"; +namespace Jellyfin.Api.Attributes; - /// - /// Initializes a new instance of the class. - /// - public AcceptsImageFileAttribute() - : base(ContentType) - { - } +/// +/// Produces file attribute of "image/*". +/// +public sealed class AcceptsImageFileAttribute : AcceptsFileAttribute +{ + private const string ContentType = "image/*"; + + /// + /// Initializes a new instance of the class. + /// + public AcceptsImageFileAttribute() + : base(ContentType) + { } } diff --git a/Jellyfin.Api/Attributes/HttpSubscribeAttribute.cs b/Jellyfin.Api/Attributes/HttpSubscribeAttribute.cs index 4dcf5976a2..cbd32ed822 100644 --- a/Jellyfin.Api/Attributes/HttpSubscribeAttribute.cs +++ b/Jellyfin.Api/Attributes/HttpSubscribeAttribute.cs @@ -2,29 +2,28 @@ using System; using System.Collections.Generic; using Microsoft.AspNetCore.Mvc.Routing; -namespace Jellyfin.Api.Attributes +namespace Jellyfin.Api.Attributes; + +/// +/// Identifies an action that supports the HTTP GET method. +/// +public sealed class HttpSubscribeAttribute : HttpMethodAttribute { + private static readonly IEnumerable _supportedMethods = new[] { "SUBSCRIBE" }; + /// - /// Identifies an action that supports the HTTP GET method. + /// Initializes a new instance of the class. /// - public sealed class HttpSubscribeAttribute : HttpMethodAttribute + public HttpSubscribeAttribute() + : base(_supportedMethods) { - private static readonly IEnumerable _supportedMethods = new[] { "SUBSCRIBE" }; - - /// - /// Initializes a new instance of the class. - /// - public HttpSubscribeAttribute() - : base(_supportedMethods) - { - } - - /// - /// Initializes a new instance of the class. - /// - /// The route template. May not be null. - public HttpSubscribeAttribute(string template) - : base(_supportedMethods, template) - => ArgumentNullException.ThrowIfNull(template); } + + /// + /// Initializes a new instance of the class. + /// + /// The route template. May not be null. + public HttpSubscribeAttribute(string template) + : base(_supportedMethods, template) + => ArgumentNullException.ThrowIfNull(template); } diff --git a/Jellyfin.Api/Attributes/HttpUnsubscribeAttribute.cs b/Jellyfin.Api/Attributes/HttpUnsubscribeAttribute.cs index d0238424a7..f4a6dcdaf9 100644 --- a/Jellyfin.Api/Attributes/HttpUnsubscribeAttribute.cs +++ b/Jellyfin.Api/Attributes/HttpUnsubscribeAttribute.cs @@ -2,29 +2,28 @@ using System; using System.Collections.Generic; using Microsoft.AspNetCore.Mvc.Routing; -namespace Jellyfin.Api.Attributes +namespace Jellyfin.Api.Attributes; + +/// +/// Identifies an action that supports the HTTP GET method. +/// +public sealed class HttpUnsubscribeAttribute : HttpMethodAttribute { + private static readonly IEnumerable _supportedMethods = new[] { "UNSUBSCRIBE" }; + /// - /// Identifies an action that supports the HTTP GET method. + /// Initializes a new instance of the class. /// - public sealed class HttpUnsubscribeAttribute : HttpMethodAttribute + public HttpUnsubscribeAttribute() + : base(_supportedMethods) { - private static readonly IEnumerable _supportedMethods = new[] { "UNSUBSCRIBE" }; - - /// - /// Initializes a new instance of the class. - /// - public HttpUnsubscribeAttribute() - : base(_supportedMethods) - { - } - - /// - /// Initializes a new instance of the class. - /// - /// The route template. May not be null. - public HttpUnsubscribeAttribute(string template) - : base(_supportedMethods, template) - => ArgumentNullException.ThrowIfNull(template); } + + /// + /// Initializes a new instance of the class. + /// + /// The route template. May not be null. + public HttpUnsubscribeAttribute(string template) + : base(_supportedMethods, template) + => ArgumentNullException.ThrowIfNull(template); } diff --git a/Jellyfin.Api/Attributes/ParameterObsoleteAttribute.cs b/Jellyfin.Api/Attributes/ParameterObsoleteAttribute.cs index 514e7ce974..bf64fef5d7 100644 --- a/Jellyfin.Api/Attributes/ParameterObsoleteAttribute.cs +++ b/Jellyfin.Api/Attributes/ParameterObsoleteAttribute.cs @@ -1,12 +1,11 @@ using System; -namespace Jellyfin.Api.Attributes +namespace Jellyfin.Api.Attributes; + +/// +/// Attribute to mark a parameter as obsolete. +/// +[AttributeUsage(AttributeTargets.Parameter)] +public sealed class ParameterObsoleteAttribute : Attribute { - /// - /// Attribute to mark a parameter as obsolete. - /// - [AttributeUsage(AttributeTargets.Parameter)] - public sealed class ParameterObsoleteAttribute : Attribute - { - } } diff --git a/Jellyfin.Api/Attributes/ProducesAudioFileAttribute.cs b/Jellyfin.Api/Attributes/ProducesAudioFileAttribute.cs index 9fc25f192e..7ce09c299d 100644 --- a/Jellyfin.Api/Attributes/ProducesAudioFileAttribute.cs +++ b/Jellyfin.Api/Attributes/ProducesAudioFileAttribute.cs @@ -1,18 +1,17 @@ -namespace Jellyfin.Api.Attributes -{ - /// - /// Produces file attribute of "image/*". - /// - public sealed class ProducesAudioFileAttribute : ProducesFileAttribute - { - private const string ContentType = "audio/*"; +namespace Jellyfin.Api.Attributes; - /// - /// Initializes a new instance of the class. - /// - public ProducesAudioFileAttribute() - : base(ContentType) - { - } +/// +/// Produces file attribute of "image/*". +/// +public sealed class ProducesAudioFileAttribute : ProducesFileAttribute +{ + private const string ContentType = "audio/*"; + + /// + /// Initializes a new instance of the class. + /// + public ProducesAudioFileAttribute() + : base(ContentType) + { } } diff --git a/Jellyfin.Api/Attributes/ProducesFileAttribute.cs b/Jellyfin.Api/Attributes/ProducesFileAttribute.cs index d8e4141acb..c728f68e07 100644 --- a/Jellyfin.Api/Attributes/ProducesFileAttribute.cs +++ b/Jellyfin.Api/Attributes/ProducesFileAttribute.cs @@ -2,29 +2,28 @@ using System; -namespace Jellyfin.Api.Attributes +namespace Jellyfin.Api.Attributes; + +/// +/// Internal produces image attribute. +/// +[AttributeUsage(AttributeTargets.Method)] +public class ProducesFileAttribute : Attribute { + private readonly string[] _contentTypes; + /// - /// Internal produces image attribute. + /// Initializes a new instance of the class. /// - [AttributeUsage(AttributeTargets.Method)] - public class ProducesFileAttribute : Attribute + /// Content types this endpoint produces. + public ProducesFileAttribute(params string[] contentTypes) { - private readonly string[] _contentTypes; - - /// - /// Initializes a new instance of the class. - /// - /// Content types this endpoint produces. - public ProducesFileAttribute(params string[] contentTypes) - { - _contentTypes = contentTypes; - } - - /// - /// Gets the configured content types. - /// - /// the configured content types. - public string[] ContentTypes => _contentTypes; + _contentTypes = contentTypes; } + + /// + /// Gets the configured content types. + /// + /// the configured content types. + public string[] ContentTypes => _contentTypes; } diff --git a/Jellyfin.Api/Attributes/ProducesImageFileAttribute.cs b/Jellyfin.Api/Attributes/ProducesImageFileAttribute.cs index 1e5b542e27..f145a061ee 100644 --- a/Jellyfin.Api/Attributes/ProducesImageFileAttribute.cs +++ b/Jellyfin.Api/Attributes/ProducesImageFileAttribute.cs @@ -1,18 +1,17 @@ -namespace Jellyfin.Api.Attributes -{ - /// - /// Produces file attribute of "image/*". - /// - public sealed class ProducesImageFileAttribute : ProducesFileAttribute - { - private const string ContentType = "image/*"; +namespace Jellyfin.Api.Attributes; - /// - /// Initializes a new instance of the class. - /// - public ProducesImageFileAttribute() - : base(ContentType) - { - } +/// +/// Produces file attribute of "image/*". +/// +public sealed class ProducesImageFileAttribute : ProducesFileAttribute +{ + private const string ContentType = "image/*"; + + /// + /// Initializes a new instance of the class. + /// + public ProducesImageFileAttribute() + : base(ContentType) + { } } diff --git a/Jellyfin.Api/Attributes/ProducesPlaylistFileAttribute.cs b/Jellyfin.Api/Attributes/ProducesPlaylistFileAttribute.cs index 5b15cb1a56..c03ed740c0 100644 --- a/Jellyfin.Api/Attributes/ProducesPlaylistFileAttribute.cs +++ b/Jellyfin.Api/Attributes/ProducesPlaylistFileAttribute.cs @@ -1,18 +1,17 @@ -namespace Jellyfin.Api.Attributes -{ - /// - /// Produces file attribute of "image/*". - /// - public sealed class ProducesPlaylistFileAttribute : ProducesFileAttribute - { - private const string ContentType = "application/x-mpegURL"; +namespace Jellyfin.Api.Attributes; - /// - /// Initializes a new instance of the class. - /// - public ProducesPlaylistFileAttribute() - : base(ContentType) - { - } +/// +/// Produces file attribute of "image/*". +/// +public sealed class ProducesPlaylistFileAttribute : ProducesFileAttribute +{ + private const string ContentType = "application/x-mpegURL"; + + /// + /// Initializes a new instance of the class. + /// + public ProducesPlaylistFileAttribute() + : base(ContentType) + { } } diff --git a/Jellyfin.Api/Attributes/ProducesVideoFileAttribute.cs b/Jellyfin.Api/Attributes/ProducesVideoFileAttribute.cs index 6857d45ecc..10dec0c00e 100644 --- a/Jellyfin.Api/Attributes/ProducesVideoFileAttribute.cs +++ b/Jellyfin.Api/Attributes/ProducesVideoFileAttribute.cs @@ -1,18 +1,17 @@ -namespace Jellyfin.Api.Attributes -{ - /// - /// Produces file attribute of "video/*". - /// - public sealed class ProducesVideoFileAttribute : ProducesFileAttribute - { - private const string ContentType = "video/*"; +namespace Jellyfin.Api.Attributes; - /// - /// Initializes a new instance of the class. - /// - public ProducesVideoFileAttribute() - : base(ContentType) - { - } +/// +/// Produces file attribute of "video/*". +/// +public sealed class ProducesVideoFileAttribute : ProducesFileAttribute +{ + private const string ContentType = "video/*"; + + /// + /// Initializes a new instance of the class. + /// + public ProducesVideoFileAttribute() + : base(ContentType) + { } } diff --git a/Jellyfin.Api/Auth/AnonymousLanAccessPolicy/AnonymousLanAccessHandler.cs b/Jellyfin.Api/Auth/AnonymousLanAccessPolicy/AnonymousLanAccessHandler.cs index d4b1ffb060..741b88ea95 100644 --- a/Jellyfin.Api/Auth/AnonymousLanAccessPolicy/AnonymousLanAccessHandler.cs +++ b/Jellyfin.Api/Auth/AnonymousLanAccessPolicy/AnonymousLanAccessHandler.cs @@ -1,4 +1,5 @@ using System.Threading.Tasks; +using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -29,7 +30,7 @@ namespace Jellyfin.Api.Auth.AnonymousLanAccessPolicy /// protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, AnonymousLanAccessRequirement requirement) { - var ip = _httpContextAccessor.HttpContext?.Connection.RemoteIpAddress; + var ip = _httpContextAccessor.HttpContext?.GetNormalizedRemoteIp(); // Loopback will be on LAN, so we can accept null. if (ip is null || _networkManager.IsInLocalNetwork(ip)) diff --git a/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs b/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs deleted file mode 100644 index 8e5e66d64a..0000000000 --- a/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs +++ /dev/null @@ -1,113 +0,0 @@ -using System.Security.Claims; -using Jellyfin.Api.Extensions; -using Jellyfin.Api.Helpers; -using Jellyfin.Data.Enums; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Library; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; - -namespace Jellyfin.Api.Auth -{ - /// - /// Base authorization handler. - /// - /// Type of Authorization Requirement. - public abstract class BaseAuthorizationHandler : AuthorizationHandler - where T : IAuthorizationRequirement - { - private readonly IUserManager _userManager; - private readonly INetworkManager _networkManager; - private readonly IHttpContextAccessor _httpContextAccessor; - - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - protected BaseAuthorizationHandler( - IUserManager userManager, - INetworkManager networkManager, - IHttpContextAccessor httpContextAccessor) - { - _userManager = userManager; - _networkManager = networkManager; - _httpContextAccessor = httpContextAccessor; - } - - /// - /// Validate authenticated claims. - /// - /// Request claims. - /// Whether to ignore parental control. - /// Whether access is to be allowed locally only. - /// Whether validation requires download permission. - /// Validated claim status. - protected bool ValidateClaims( - ClaimsPrincipal claimsPrincipal, - bool ignoreSchedule = false, - bool localAccessOnly = false, - bool requiredDownloadPermission = false) - { - // ApiKey is currently global admin, always allow. - var isApiKey = claimsPrincipal.GetIsApiKey(); - if (isApiKey) - { - return true; - } - - // Ensure claim has userId. - var userId = claimsPrincipal.GetUserId(); - if (userId.Equals(default)) - { - return false; - } - - // Ensure userId links to a valid user. - var user = _userManager.GetUserById(userId); - if (user is null) - { - return false; - } - - // Ensure user is not disabled. - if (user.HasPermission(PermissionKind.IsDisabled)) - { - return false; - } - - var isInLocalNetwork = _httpContextAccessor.HttpContext is not null - && _networkManager.IsInLocalNetwork(_httpContextAccessor.HttpContext.GetNormalizedRemoteIp()); - - // User cannot access remotely and user is remote - if (!user.HasPermission(PermissionKind.EnableRemoteAccess) && !isInLocalNetwork) - { - return false; - } - - if (localAccessOnly && !isInLocalNetwork) - { - return false; - } - - // User attempting to access out of parental control hours. - if (!ignoreSchedule - && !user.HasPermission(PermissionKind.IsAdministrator) - && !user.IsParentalScheduleAllowed()) - { - return false; - } - - // User attempting to download without permission. - if (requiredDownloadPermission - && !user.HasPermission(PermissionKind.EnableContentDownloading)) - { - return false; - } - - return true; - } - } -} diff --git a/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs b/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs index be77b7a4e4..de271ab640 100644 --- a/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs +++ b/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs @@ -1,4 +1,8 @@ using System.Threading.Tasks; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Extensions; +using Jellyfin.Data.Enums; +using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Library; using Microsoft.AspNetCore.Authorization; @@ -9,8 +13,12 @@ namespace Jellyfin.Api.Auth.DefaultAuthorizationPolicy /// /// Default authorization handler. /// - public class DefaultAuthorizationHandler : BaseAuthorizationHandler + public class DefaultAuthorizationHandler : AuthorizationHandler { + private readonly IUserManager _userManager; + private readonly INetworkManager _networkManager; + private readonly IHttpContextAccessor _httpContextAccessor; + /// /// Initializes a new instance of the class. /// @@ -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; } /// 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)) { - context.Succeed(requirement); + return Task.CompletedTask; } - else + + 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; + } + + // It's not great to have this check, but parental schedule must usually be honored except in a few rare cases + if (requirement.ValidateParentalSchedule && !user.IsParentalScheduleAllowed()) + { + context.Fail(); + return Task.CompletedTask; + } + + // Only succeed if the requirement isn't a subclass as any subclassed requirement will handle success in its own handler + if (requirement.GetType() == typeof(DefaultAuthorizationRequirement)) + { + context.Succeed(requirement); } return Task.CompletedTask; diff --git a/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationRequirement.cs b/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationRequirement.cs index 7cea00b694..5ba1bc330d 100644 --- a/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationRequirement.cs +++ b/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationRequirement.cs @@ -7,5 +7,18 @@ namespace Jellyfin.Api.Auth.DefaultAuthorizationPolicy /// public class DefaultAuthorizationRequirement : IAuthorizationRequirement { + /// + /// Initializes a new instance of the class. + /// + /// A value indicating whether to validate parental schedule. + public DefaultAuthorizationRequirement(bool validateParentalSchedule = true) + { + ValidateParentalSchedule = validateParentalSchedule; + } + + /// + /// Gets a value indicating whether to ignore parental schedule. + /// + public bool ValidateParentalSchedule { get; } } } diff --git a/Jellyfin.Api/Auth/DownloadPolicy/DownloadHandler.cs b/Jellyfin.Api/Auth/DownloadPolicy/DownloadHandler.cs deleted file mode 100644 index b61680ab1a..0000000000 --- a/Jellyfin.Api/Auth/DownloadPolicy/DownloadHandler.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Threading.Tasks; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Library; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; - -namespace Jellyfin.Api.Auth.DownloadPolicy -{ - /// - /// Download authorization handler. - /// - public class DownloadHandler : BaseAuthorizationHandler - { - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - public DownloadHandler( - IUserManager userManager, - INetworkManager networkManager, - IHttpContextAccessor httpContextAccessor) - : base(userManager, networkManager, httpContextAccessor) - { - } - - /// - protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, DownloadRequirement requirement) - { - var validated = ValidateClaims(context.User); - if (validated) - { - context.Succeed(requirement); - } - else - { - context.Fail(); - } - - return Task.CompletedTask; - } - } -} diff --git a/Jellyfin.Api/Auth/DownloadPolicy/DownloadRequirement.cs b/Jellyfin.Api/Auth/DownloadPolicy/DownloadRequirement.cs deleted file mode 100644 index b0a72a9dec..0000000000 --- a/Jellyfin.Api/Auth/DownloadPolicy/DownloadRequirement.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Microsoft.AspNetCore.Authorization; - -namespace Jellyfin.Api.Auth.DownloadPolicy -{ - /// - /// The download permission requirement. - /// - public class DownloadRequirement : IAuthorizationRequirement - { - } -} diff --git a/Jellyfin.Api/Auth/FirstTimeOrIgnoreParentalControlSetupPolicy/FirstTimeOrIgnoreParentalControlSetupHandler.cs b/Jellyfin.Api/Auth/FirstTimeOrIgnoreParentalControlSetupPolicy/FirstTimeOrIgnoreParentalControlSetupHandler.cs deleted file mode 100644 index 31482a930f..0000000000 --- a/Jellyfin.Api/Auth/FirstTimeOrIgnoreParentalControlSetupPolicy/FirstTimeOrIgnoreParentalControlSetupHandler.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System.Threading.Tasks; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Library; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; - -namespace Jellyfin.Api.Auth.FirstTimeOrIgnoreParentalControlSetupPolicy -{ - /// - /// Ignore parental control schedule and allow before startup wizard has been completed. - /// - public class FirstTimeOrIgnoreParentalControlSetupHandler : BaseAuthorizationHandler - { - private readonly IConfigurationManager _configurationManager; - - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - public FirstTimeOrIgnoreParentalControlSetupHandler( - IUserManager userManager, - INetworkManager networkManager, - IHttpContextAccessor httpContextAccessor, - IConfigurationManager configurationManager) - : base(userManager, networkManager, httpContextAccessor) - { - _configurationManager = configurationManager; - } - - /// - protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, FirstTimeOrIgnoreParentalControlSetupRequirement requirement) - { - if (!_configurationManager.CommonConfiguration.IsStartupWizardCompleted) - { - context.Succeed(requirement); - return Task.CompletedTask; - } - - var validated = ValidateClaims(context.User, ignoreSchedule: true); - if (validated) - { - context.Succeed(requirement); - } - else - { - context.Fail(); - } - - return Task.CompletedTask; - } - } -} diff --git a/Jellyfin.Api/Auth/FirstTimeOrIgnoreParentalControlSetupPolicy/FirstTimeOrIgnoreParentalControlSetupRequirement.cs b/Jellyfin.Api/Auth/FirstTimeOrIgnoreParentalControlSetupPolicy/FirstTimeOrIgnoreParentalControlSetupRequirement.cs deleted file mode 100644 index 00aaec334b..0000000000 --- a/Jellyfin.Api/Auth/FirstTimeOrIgnoreParentalControlSetupPolicy/FirstTimeOrIgnoreParentalControlSetupRequirement.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Microsoft.AspNetCore.Authorization; - -namespace Jellyfin.Api.Auth.FirstTimeOrIgnoreParentalControlSetupPolicy -{ - /// - /// First time setup or ignore parental controls requirement. - /// - public class FirstTimeOrIgnoreParentalControlSetupRequirement : IAuthorizationRequirement - { - } -} diff --git a/Jellyfin.Api/Auth/FirstTimeSetupOrDefaultPolicy/FirstTimeSetupOrDefaultHandler.cs b/Jellyfin.Api/Auth/FirstTimeSetupOrDefaultPolicy/FirstTimeSetupOrDefaultHandler.cs deleted file mode 100644 index dd0bd4ec2f..0000000000 --- a/Jellyfin.Api/Auth/FirstTimeSetupOrDefaultPolicy/FirstTimeSetupOrDefaultHandler.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System.Threading.Tasks; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Library; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; - -namespace Jellyfin.Api.Auth.FirstTimeSetupOrDefaultPolicy -{ - /// - /// Authorization handler for requiring first time setup or default privileges. - /// - public class FirstTimeSetupOrDefaultHandler : BaseAuthorizationHandler - { - private readonly IConfigurationManager _configurationManager; - - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - public FirstTimeSetupOrDefaultHandler( - IConfigurationManager configurationManager, - IUserManager userManager, - INetworkManager networkManager, - IHttpContextAccessor httpContextAccessor) - : base(userManager, networkManager, httpContextAccessor) - { - _configurationManager = configurationManager; - } - - /// - protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, FirstTimeSetupOrDefaultRequirement requirement) - { - if (!_configurationManager.CommonConfiguration.IsStartupWizardCompleted) - { - context.Succeed(requirement); - return Task.CompletedTask; - } - - var validated = ValidateClaims(context.User); - if (validated) - { - context.Succeed(requirement); - } - else - { - context.Fail(); - } - - return Task.CompletedTask; - } - } -} diff --git a/Jellyfin.Api/Auth/FirstTimeSetupOrDefaultPolicy/FirstTimeSetupOrDefaultRequirement.cs b/Jellyfin.Api/Auth/FirstTimeSetupOrDefaultPolicy/FirstTimeSetupOrDefaultRequirement.cs deleted file mode 100644 index f7366bd7a9..0000000000 --- a/Jellyfin.Api/Auth/FirstTimeSetupOrDefaultPolicy/FirstTimeSetupOrDefaultRequirement.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Microsoft.AspNetCore.Authorization; - -namespace Jellyfin.Api.Auth.FirstTimeSetupOrDefaultPolicy -{ - /// - /// The authorization requirement, requiring incomplete first time setup or default privileges, for the authorization handler. - /// - public class FirstTimeSetupOrDefaultRequirement : IAuthorizationRequirement - { - } -} diff --git a/Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedRequirement.cs b/Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedRequirement.cs deleted file mode 100644 index 51ba637b60..0000000000 --- a/Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedRequirement.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Microsoft.AspNetCore.Authorization; - -namespace Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy -{ - /// - /// The authorization requirement, requiring incomplete first time setup or elevated privileges, for the authorization handler. - /// - public class FirstTimeSetupOrElevatedRequirement : IAuthorizationRequirement - { - } -} diff --git a/Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandler.cs b/Jellyfin.Api/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandler.cs similarity index 51% rename from Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandler.cs rename to Jellyfin.Api/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandler.cs index 90b76ee99a..688a13bc0b 100644 --- a/Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandler.cs +++ b/Jellyfin.Api/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandler.cs @@ -1,39 +1,36 @@ using System.Threading.Tasks; using Jellyfin.Api.Constants; +using Jellyfin.Api.Extensions; using MediaBrowser.Common.Configuration; -using MediaBrowser.Common.Net; +using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Library; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; -namespace Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy +namespace Jellyfin.Api.Auth.FirstTimeSetupPolicy { /// - /// Authorization handler for requiring first time setup or elevated privileges. + /// Authorization handler for requiring first time setup or default privileges. /// - public class FirstTimeSetupOrElevatedHandler : BaseAuthorizationHandler + public class FirstTimeSetupHandler : AuthorizationHandler { private readonly IConfigurationManager _configurationManager; + private readonly IUserManager _userManager; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// Instance of the interface. /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - public FirstTimeSetupOrElevatedHandler( + public FirstTimeSetupHandler( IConfigurationManager configurationManager, - IUserManager userManager, - INetworkManager networkManager, - IHttpContextAccessor httpContextAccessor) - : base(userManager, networkManager, httpContextAccessor) + IUserManager userManager) { _configurationManager = configurationManager; + _userManager = userManager; } /// - protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, FirstTimeSetupOrElevatedRequirement requirement) + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, FirstTimeSetupRequirement requirement) { if (!_configurationManager.CommonConfiguration.IsStartupWizardCompleted) { @@ -41,14 +38,35 @@ namespace Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy return Task.CompletedTask; } - var validated = ValidateClaims(context.User); - if (validated && context.User.IsInRole(UserRoles.Administrator)) - { - context.Succeed(requirement); - } - else + var contextUser = context.User; + if (requirement.RequireAdmin && !contextUser.IsInRole(UserRoles.Administrator)) { context.Fail(); + return Task.CompletedTask; + } + + var userId = contextUser.GetUserId(); + if (userId.Equals(default)) + { + context.Fail(); + return Task.CompletedTask; + } + + if (!requirement.ValidateParentalSchedule) + { + context.Succeed(requirement); + return Task.CompletedTask; + } + + var user = _userManager.GetUserById(userId); + if (user is null) + { + throw new ResourceNotFoundException(); + } + + if (user.IsParentalScheduleAllowed()) + { + context.Succeed(requirement); } return Task.CompletedTask; diff --git a/Jellyfin.Api/Auth/FirstTimeSetupPolicy/FirstTimeSetupRequirement.cs b/Jellyfin.Api/Auth/FirstTimeSetupPolicy/FirstTimeSetupRequirement.cs new file mode 100644 index 0000000000..6252a2feb8 --- /dev/null +++ b/Jellyfin.Api/Auth/FirstTimeSetupPolicy/FirstTimeSetupRequirement.cs @@ -0,0 +1,25 @@ +using Jellyfin.Api.Auth.DefaultAuthorizationPolicy; + +namespace Jellyfin.Api.Auth.FirstTimeSetupPolicy +{ + /// + /// The authorization requirement, requiring incomplete first time setup or default privileges, for the authorization handler. + /// + public class FirstTimeSetupRequirement : DefaultAuthorizationRequirement + { + /// + /// Initializes a new instance of the class. + /// + /// A value indicating whether to ignore parental schedule. + /// A value indicating whether administrator role is required. + public FirstTimeSetupRequirement(bool validateParentalSchedule = false, bool requireAdmin = true) : base(validateParentalSchedule) + { + RequireAdmin = requireAdmin; + } + + /// + /// Gets a value indicating whether administrator role is required. + /// + public bool RequireAdmin { get; } + } +} diff --git a/Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlHandler.cs b/Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlHandler.cs deleted file mode 100644 index a7623556a9..0000000000 --- a/Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlHandler.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Threading.Tasks; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Library; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; - -namespace Jellyfin.Api.Auth.IgnoreParentalControlPolicy -{ - /// - /// Escape schedule controls handler. - /// - public class IgnoreParentalControlHandler : BaseAuthorizationHandler - { - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - public IgnoreParentalControlHandler( - IUserManager userManager, - INetworkManager networkManager, - IHttpContextAccessor httpContextAccessor) - : base(userManager, networkManager, httpContextAccessor) - { - } - - /// - protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, IgnoreParentalControlRequirement requirement) - { - var validated = ValidateClaims(context.User, ignoreSchedule: true); - if (validated) - { - context.Succeed(requirement); - } - else - { - context.Fail(); - } - - return Task.CompletedTask; - } - } -} diff --git a/Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlRequirement.cs b/Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlRequirement.cs deleted file mode 100644 index cdad74270e..0000000000 --- a/Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlRequirement.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Microsoft.AspNetCore.Authorization; - -namespace Jellyfin.Api.Auth.IgnoreParentalControlPolicy -{ - /// - /// Escape schedule controls requirement. - /// - public class IgnoreParentalControlRequirement : IAuthorizationRequirement - { - } -} diff --git a/Jellyfin.Api/Auth/LocalAccessOrRequiresElevationPolicy/LocalAccessOrRequiresElevationHandler.cs b/Jellyfin.Api/Auth/LocalAccessOrRequiresElevationPolicy/LocalAccessOrRequiresElevationHandler.cs index 14722aa57e..6ed6fc90be 100644 --- a/Jellyfin.Api/Auth/LocalAccessOrRequiresElevationPolicy/LocalAccessOrRequiresElevationHandler.cs +++ b/Jellyfin.Api/Auth/LocalAccessOrRequiresElevationPolicy/LocalAccessOrRequiresElevationHandler.cs @@ -1,7 +1,7 @@ -using System.Threading.Tasks; +using System.Threading.Tasks; using Jellyfin.Api.Constants; +using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Library; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -10,27 +10,38 @@ namespace Jellyfin.Api.Auth.LocalAccessOrRequiresElevationPolicy /// /// Local access or require elevated privileges handler. /// - public class LocalAccessOrRequiresElevationHandler : BaseAuthorizationHandler + public class LocalAccessOrRequiresElevationHandler : AuthorizationHandler { + private readonly INetworkManager _networkManager; + private readonly IHttpContextAccessor _httpContextAccessor; + /// /// Initializes a new instance of the class. /// - /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. public LocalAccessOrRequiresElevationHandler( - IUserManager userManager, INetworkManager networkManager, IHttpContextAccessor httpContextAccessor) - : base(userManager, networkManager, httpContextAccessor) { + _networkManager = networkManager; + _httpContextAccessor = httpContextAccessor; } /// protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, LocalAccessOrRequiresElevationRequirement requirement) { - var validated = ValidateClaims(context.User, localAccessOnly: true); - if (validated || context.User.IsInRole(UserRoles.Administrator)) + var ip = _httpContextAccessor.HttpContext?.GetNormalizedRemoteIp(); + + // Loopback will be on LAN, so we can accept null. + if (ip is null || _networkManager.IsInLocalNetwork(ip)) + { + context.Succeed(requirement); + + return Task.CompletedTask; + } + + if (context.User.IsInRole(UserRoles.Administrator)) { context.Succeed(requirement); } diff --git a/Jellyfin.Api/Auth/LocalAccessOrRequiresElevationPolicy/LocalAccessOrRequiresElevationRequirement.cs b/Jellyfin.Api/Auth/LocalAccessOrRequiresElevationPolicy/LocalAccessOrRequiresElevationRequirement.cs index d9c64d01c4..f633c69d8f 100644 --- a/Jellyfin.Api/Auth/LocalAccessOrRequiresElevationPolicy/LocalAccessOrRequiresElevationRequirement.cs +++ b/Jellyfin.Api/Auth/LocalAccessOrRequiresElevationPolicy/LocalAccessOrRequiresElevationRequirement.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization; namespace Jellyfin.Api.Auth.LocalAccessOrRequiresElevationPolicy { diff --git a/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs b/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs deleted file mode 100644 index d772ec5542..0000000000 --- a/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Threading.Tasks; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Library; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; - -namespace Jellyfin.Api.Auth.LocalAccessPolicy -{ - /// - /// Local access handler. - /// - public class LocalAccessHandler : BaseAuthorizationHandler - { - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - public LocalAccessHandler( - IUserManager userManager, - INetworkManager networkManager, - IHttpContextAccessor httpContextAccessor) - : base(userManager, networkManager, httpContextAccessor) - { - } - - /// - protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, LocalAccessRequirement requirement) - { - var validated = ValidateClaims(context.User, localAccessOnly: true); - if (validated) - { - context.Succeed(requirement); - } - else - { - context.Fail(); - } - - return Task.CompletedTask; - } - } -} diff --git a/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessRequirement.cs b/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessRequirement.cs deleted file mode 100644 index 761127fa40..0000000000 --- a/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessRequirement.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Microsoft.AspNetCore.Authorization; - -namespace Jellyfin.Api.Auth.LocalAccessPolicy -{ - /// - /// The local access authorization requirement. - /// - public class LocalAccessRequirement : IAuthorizationRequirement - { - } -} diff --git a/Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationHandler.cs b/Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationHandler.cs deleted file mode 100644 index b235c4b63b..0000000000 --- a/Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationHandler.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.Threading.Tasks; -using Jellyfin.Api.Constants; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Library; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; - -namespace Jellyfin.Api.Auth.RequiresElevationPolicy -{ - /// - /// Authorization handler for requiring elevated privileges. - /// - public class RequiresElevationHandler : BaseAuthorizationHandler - { - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - public RequiresElevationHandler( - IUserManager userManager, - INetworkManager networkManager, - IHttpContextAccessor httpContextAccessor) - : base(userManager, networkManager, httpContextAccessor) - { - } - - /// - protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, RequiresElevationRequirement requirement) - { - var validated = ValidateClaims(context.User); - if (validated && context.User.IsInRole(UserRoles.Administrator)) - { - context.Succeed(requirement); - } - else - { - context.Fail(); - } - - return Task.CompletedTask; - } - } -} diff --git a/Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationRequirement.cs b/Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationRequirement.cs deleted file mode 100644 index cfff1cc0c5..0000000000 --- a/Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationRequirement.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Microsoft.AspNetCore.Authorization; - -namespace Jellyfin.Api.Auth.RequiresElevationPolicy -{ - /// - /// The authorization requirement for requiring elevated privileges in the authorization handler. - /// - public class RequiresElevationRequirement : IAuthorizationRequirement - { - } -} diff --git a/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs index cdd7d8a52b..75ec9fcec6 100644 --- a/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs +++ b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs @@ -1,19 +1,17 @@ using System.Threading.Tasks; using Jellyfin.Api.Extensions; -using Jellyfin.Api.Helpers; using Jellyfin.Data.Enums; -using MediaBrowser.Common.Net; +using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.SyncPlay; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy { /// /// Default authorization handler. /// - public class SyncPlayAccessHandler : BaseAuthorizationHandler + public class SyncPlayAccessHandler : AuthorizationHandler { private readonly ISyncPlayManager _syncPlayManager; private readonly IUserManager _userManager; @@ -23,14 +21,9 @@ namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy /// /// Instance of the interface. /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. 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 /// protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, SyncPlayAccessRequirement requirement) { - if (!ValidateClaims(context.User)) - { - context.Fail(); - return Task.CompletedTask; - } - var userId = context.User.GetUserId(); var user = _userManager.GetUserById(userId); + if (user is null) + { + throw new ResourceNotFoundException(); + } if (requirement.RequiredAccess == SyncPlayAccessRequirementType.HasAccess) { - if (user.SyncPlayAccess == SyncPlayUserAccessType.CreateAndJoinGroups - || user.SyncPlayAccess == SyncPlayUserAccessType.JoinGroups + if (user.SyncPlayAccess is SyncPlayUserAccessType.CreateAndJoinGroups or SyncPlayUserAccessType.JoinGroups || _syncPlayManager.IsUserActive(userId)) { context.Succeed(requirement); } - else - { - context.Fail(); - } } else if (requirement.RequiredAccess == SyncPlayAccessRequirementType.CreateGroup) { @@ -67,10 +53,6 @@ namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy { context.Succeed(requirement); } - else - { - context.Fail(); - } } else if (requirement.RequiredAccess == SyncPlayAccessRequirementType.JoinGroup) { @@ -79,10 +61,6 @@ namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy { context.Succeed(requirement); } - else - { - context.Fail(); - } } else if (requirement.RequiredAccess == SyncPlayAccessRequirementType.IsInGroup) { @@ -90,14 +68,6 @@ namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy { context.Succeed(requirement); } - else - { - context.Fail(); - } - } - else - { - context.Fail(); } return Task.CompletedTask; diff --git a/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessRequirement.cs b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessRequirement.cs index 6fab4c0ad8..220b223b39 100644 --- a/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessRequirement.cs +++ b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessRequirement.cs @@ -1,12 +1,12 @@ -using Jellyfin.Data.Enums; -using Microsoft.AspNetCore.Authorization; +using Jellyfin.Api.Auth.DefaultAuthorizationPolicy; +using Jellyfin.Data.Enums; namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy { /// /// The default authorization requirement. /// - public class SyncPlayAccessRequirement : IAuthorizationRequirement + public class SyncPlayAccessRequirement : DefaultAuthorizationRequirement { /// /// Initializes a new instance of the class. diff --git a/Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionHandler.cs b/Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionHandler.cs new file mode 100644 index 0000000000..e72bec46fd --- /dev/null +++ b/Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionHandler.cs @@ -0,0 +1,42 @@ +using System.Threading.Tasks; +using Jellyfin.Api.Extensions; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Library; +using Microsoft.AspNetCore.Authorization; + +namespace Jellyfin.Api.Auth.UserPermissionPolicy +{ + /// + /// User permission authorization handler. + /// + public class UserPermissionHandler : AuthorizationHandler + { + private readonly IUserManager _userManager; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + public UserPermissionHandler(IUserManager userManager) + { + _userManager = userManager; + } + + /// + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, UserPermissionRequirement requirement) + { + var user = _userManager.GetUserById(context.User.GetUserId()); + if (user is null) + { + throw new ResourceNotFoundException(); + } + + if (user.HasPermission(requirement.RequiredPermission)) + { + context.Succeed(requirement); + } + + return Task.CompletedTask; + } + } +} diff --git a/Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionRequirement.cs b/Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionRequirement.cs new file mode 100644 index 0000000000..4694556eb7 --- /dev/null +++ b/Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionRequirement.cs @@ -0,0 +1,26 @@ +using Jellyfin.Api.Auth.DefaultAuthorizationPolicy; +using Jellyfin.Data.Enums; + +namespace Jellyfin.Api.Auth.UserPermissionPolicy +{ + /// + /// The user permission requirement. + /// + public class UserPermissionRequirement : DefaultAuthorizationRequirement + { + /// + /// Initializes a new instance of the class. + /// + /// The required . + /// Whether to validate the user's parental schedule. + public UserPermissionRequirement(PermissionKind requiredPermission, bool validateParentalSchedule = true) : base(validateParentalSchedule) + { + RequiredPermission = requiredPermission; + } + + /// + /// Gets the required user permission. + /// + public PermissionKind RequiredPermission { get; } + } +} diff --git a/Jellyfin.Api/BaseJellyfinApiController.cs b/Jellyfin.Api/BaseJellyfinApiController.cs index e327831fe7..5b4bd0adb0 100644 --- a/Jellyfin.Api/BaseJellyfinApiController.cs +++ b/Jellyfin.Api/BaseJellyfinApiController.cs @@ -4,35 +4,34 @@ using Jellyfin.Api.Results; using Jellyfin.Extensions.Json; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api +namespace Jellyfin.Api; + +/// +/// Base api controller for the API setting a default route. +/// +[ApiController] +[Route("[controller]")] +[Produces( + MediaTypeNames.Application.Json, + JsonDefaults.CamelCaseMediaType, + JsonDefaults.PascalCaseMediaType)] +public class BaseJellyfinApiController : ControllerBase { /// - /// Base api controller for the API setting a default route. + /// Create a new . /// - [ApiController] - [Route("[controller]")] - [Produces( - MediaTypeNames.Application.Json, - JsonDefaults.CamelCaseMediaType, - JsonDefaults.PascalCaseMediaType)] - public class BaseJellyfinApiController : ControllerBase - { - /// - /// Create a new . - /// - /// The value to return. - /// The type to return. - /// The . - protected ActionResult> Ok(IEnumerable? value) - => new OkResult?>(value); + /// The value to return. + /// The type to return. + /// The . + protected ActionResult> Ok(IEnumerable? value) + => new OkResult?>(value); - /// - /// Create a new . - /// - /// The value to return. - /// The type to return. - /// The . - protected ActionResult Ok(T value) - => new OkResult(value); - } + /// + /// Create a new . + /// + /// The value to return. + /// The type to return. + /// The . + protected ActionResult Ok(T value) + => new OkResult(value); } diff --git a/Jellyfin.Api/Constants/AuthenticationSchemes.cs b/Jellyfin.Api/Constants/AuthenticationSchemes.cs index bac3379e71..d5c2253e4a 100644 --- a/Jellyfin.Api/Constants/AuthenticationSchemes.cs +++ b/Jellyfin.Api/Constants/AuthenticationSchemes.cs @@ -1,13 +1,12 @@ -namespace Jellyfin.Api.Constants +namespace Jellyfin.Api.Constants; + +/// +/// Authentication schemes for user authentication in the API. +/// +public static class AuthenticationSchemes { /// - /// Authentication schemes for user authentication in the API. + /// Scheme name for the custom legacy authentication. /// - public static class AuthenticationSchemes - { - /// - /// Scheme name for the custom legacy authentication. - /// - public const string CustomAuthentication = "CustomAuthentication"; - } + public const string CustomAuthentication = "CustomAuthentication"; } diff --git a/Jellyfin.Api/Constants/InternalClaimTypes.cs b/Jellyfin.Api/Constants/InternalClaimTypes.cs index 8323312e51..73c4acb882 100644 --- a/Jellyfin.Api/Constants/InternalClaimTypes.cs +++ b/Jellyfin.Api/Constants/InternalClaimTypes.cs @@ -1,43 +1,42 @@ -namespace Jellyfin.Api.Constants +namespace Jellyfin.Api.Constants; + +/// +/// Internal claim types for authorization. +/// +public static class InternalClaimTypes { /// - /// Internal claim types for authorization. + /// User Id. /// - public static class InternalClaimTypes - { - /// - /// User Id. - /// - public const string UserId = "Jellyfin-UserId"; + public const string UserId = "Jellyfin-UserId"; - /// - /// Device Id. - /// - public const string DeviceId = "Jellyfin-DeviceId"; + /// + /// Device Id. + /// + public const string DeviceId = "Jellyfin-DeviceId"; - /// - /// Device. - /// - public const string Device = "Jellyfin-Device"; + /// + /// Device. + /// + public const string Device = "Jellyfin-Device"; - /// - /// Client. - /// - public const string Client = "Jellyfin-Client"; + /// + /// Client. + /// + public const string Client = "Jellyfin-Client"; - /// - /// Version. - /// - public const string Version = "Jellyfin-Version"; + /// + /// Version. + /// + public const string Version = "Jellyfin-Version"; - /// - /// Token. - /// - public const string Token = "Jellyfin-Token"; + /// + /// Token. + /// + public const string Token = "Jellyfin-Token"; - /// - /// Is Api Key. - /// - public const string IsApiKey = "Jellyfin-IsApiKey"; - } + /// + /// Is Api Key. + /// + public const string IsApiKey = "Jellyfin-IsApiKey"; } diff --git a/Jellyfin.Api/Constants/Policies.cs b/Jellyfin.Api/Constants/Policies.cs index a72eeea284..53841b0c44 100644 --- a/Jellyfin.Api/Constants/Policies.cs +++ b/Jellyfin.Api/Constants/Policies.cs @@ -1,78 +1,87 @@ -namespace Jellyfin.Api.Constants +namespace Jellyfin.Api.Constants; + +/// +/// Policies for the API authorization. +/// +public static class Policies { /// - /// Policies for the API authorization. + /// Policy name for requiring first time setup or elevated privileges. /// - public static class Policies - { - /// - /// Policy name for default authorization. - /// - public const string DefaultAuthorization = "DefaultAuthorization"; + public const string FirstTimeSetupOrElevated = "FirstTimeSetupOrElevated"; - /// - /// Policy name for requiring first time setup or elevated privileges. - /// - public const string FirstTimeSetupOrElevated = "FirstTimeSetupOrElevated"; + /// + /// Policy name for requiring elevated privileges. + /// + public const string RequiresElevation = "RequiresElevation"; - /// - /// Policy name for requiring elevated privileges. - /// - public const string RequiresElevation = "RequiresElevation"; + /// + /// Policy name for allowing local access only. + /// + public const string LocalAccessOnly = "LocalAccessOnly"; - /// - /// Policy name for allowing local access only. - /// - public const string LocalAccessOnly = "LocalAccessOnly"; + /// + /// Policy name for escaping schedule controls. + /// + public const string IgnoreParentalControl = "IgnoreParentalControl"; - /// - /// Policy name for escaping schedule controls. - /// - public const string IgnoreParentalControl = "IgnoreParentalControl"; + /// + /// Policy name for requiring download permission. + /// + public const string Download = "Download"; - /// - /// Policy name for requiring download permission. - /// - public const string Download = "Download"; + /// + /// Policy name for requiring first time setup or default permissions. + /// + public const string FirstTimeSetupOrDefault = "FirstTimeSetupOrDefault"; - /// - /// Policy name for requiring first time setup or default permissions. - /// - public const string FirstTimeSetupOrDefault = "FirstTimeSetupOrDefault"; + /// + /// Policy name for requiring local access or elevated privileges. + /// + public const string LocalAccessOrRequiresElevation = "LocalAccessOrRequiresElevation"; - /// - /// Policy name for requiring local access or elevated privileges. - /// - public const string LocalAccessOrRequiresElevation = "LocalAccessOrRequiresElevation"; + /// + /// Policy name for requiring (anonymous) LAN access. + /// + public const string AnonymousLanAccessPolicy = "AnonymousLanAccessPolicy"; - /// - /// Policy name for requiring (anonymous) LAN access. - /// - public const string AnonymousLanAccessPolicy = "AnonymousLanAccessPolicy"; + /// + /// Policy name for escaping schedule controls or requiring first time setup. + /// + public const string FirstTimeSetupOrIgnoreParentalControl = "FirstTimeSetupOrIgnoreParentalControl"; - /// - /// Policy name for escaping schedule controls or requiring first time setup. - /// - public const string FirstTimeSetupOrIgnoreParentalControl = "FirstTimeSetupOrIgnoreParentalControl"; + /// + /// Policy name for accessing SyncPlay. + /// + public const string SyncPlayHasAccess = "SyncPlayHasAccess"; - /// - /// Policy name for accessing SyncPlay. - /// - public const string SyncPlayHasAccess = "SyncPlayHasAccess"; + /// + /// Policy name for creating a SyncPlay group. + /// + public const string SyncPlayCreateGroup = "SyncPlayCreateGroup"; - /// - /// Policy name for creating a SyncPlay group. - /// - public const string SyncPlayCreateGroup = "SyncPlayCreateGroup"; + /// + /// Policy name for joining a SyncPlay group. + /// + public const string SyncPlayJoinGroup = "SyncPlayJoinGroup"; - /// - /// Policy name for joining a SyncPlay group. - /// - public const string SyncPlayJoinGroup = "SyncPlayJoinGroup"; + /// + /// Policy name for accessing a SyncPlay group. + /// + public const string SyncPlayIsInGroup = "SyncPlayIsInGroup"; - /// - /// Policy name for accessing a SyncPlay group. - /// - public const string SyncPlayIsInGroup = "SyncPlayIsInGroup"; - } + /// + /// Policy name for accessing collection management. + /// + public const string CollectionManagement = "CollectionManagement"; + + /// + /// Policy name for accessing LiveTV. + /// + public const string LiveTvAccess = "LiveTvAccess"; + + /// + /// Policy name for managing LiveTV. + /// + public const string LiveTvManagement = "LiveTvManagement"; } diff --git a/Jellyfin.Api/Constants/UserRoles.cs b/Jellyfin.Api/Constants/UserRoles.cs index d9a536e7d7..41c7b7cd0f 100644 --- a/Jellyfin.Api/Constants/UserRoles.cs +++ b/Jellyfin.Api/Constants/UserRoles.cs @@ -1,23 +1,22 @@ -namespace Jellyfin.Api.Constants +namespace Jellyfin.Api.Constants; + +/// +/// Constants for user roles used in the authentication and authorization for the API. +/// +public static class UserRoles { /// - /// Constants for user roles used in the authentication and authorization for the API. + /// Guest user. /// - public static class UserRoles - { - /// - /// Guest user. - /// - public const string Guest = "Guest"; + public const string Guest = "Guest"; - /// - /// Regular user with no special privileges. - /// - public const string User = "User"; + /// + /// Regular user with no special privileges. + /// + public const string User = "User"; - /// - /// Administrator user with elevated privileges. - /// - public const string Administrator = "Administrator"; - } + /// + /// Administrator user with elevated privileges. + /// + public const string Administrator = "Administrator"; } diff --git a/Jellyfin.Api/Controllers/ActivityLogController.cs b/Jellyfin.Api/Controllers/ActivityLogController.cs index ae45f647f7..c3d02976eb 100644 --- a/Jellyfin.Api/Controllers/ActivityLogController.cs +++ b/Jellyfin.Api/Controllers/ActivityLogController.cs @@ -8,50 +8,49 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// Activity log controller. +/// +[Route("System/ActivityLog")] +[Authorize(Policy = Policies.RequiresElevation)] +public class ActivityLogController : BaseJellyfinApiController { + private readonly IActivityManager _activityManager; + /// - /// Activity log controller. + /// Initializes a new instance of the class. /// - [Route("System/ActivityLog")] - [Authorize(Policy = Policies.RequiresElevation)] - public class ActivityLogController : BaseJellyfinApiController + /// Instance of interface. + public ActivityLogController(IActivityManager activityManager) { - private readonly IActivityManager _activityManager; + _activityManager = activityManager; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of interface. - public ActivityLogController(IActivityManager activityManager) + /// + /// Gets activity log entries. + /// + /// Optional. The record index to start at. All items with a lower index will be dropped from the results. + /// Optional. The maximum number of records to return. + /// Optional. The minimum date. Format = ISO. + /// Optional. Filter log entries if it has user id, or not. + /// Activity log returned. + /// A containing the log entries. + [HttpGet("Entries")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task>> GetLogEntries( + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] DateTime? minDate, + [FromQuery] bool? hasUserId) + { + return await _activityManager.GetPagedResultAsync(new ActivityLogQuery { - _activityManager = activityManager; - } - - /// - /// Gets activity log entries. - /// - /// Optional. The record index to start at. All items with a lower index will be dropped from the results. - /// Optional. The maximum number of records to return. - /// Optional. The minimum date. Format = ISO. - /// Optional. Filter log entries if it has user id, or not. - /// Activity log returned. - /// A containing the log entries. - [HttpGet("Entries")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task>> GetLogEntries( - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery] DateTime? minDate, - [FromQuery] bool? hasUserId) - { - return await _activityManager.GetPagedResultAsync(new ActivityLogQuery - { - Skip = startIndex, - Limit = limit, - MinDate = minDate, - HasUserId = hasUserId - }).ConfigureAwait(false); - } + Skip = startIndex, + Limit = limit, + MinDate = minDate, + HasUserId = hasUserId + }).ConfigureAwait(false); } } diff --git a/Jellyfin.Api/Controllers/ApiKeyController.cs b/Jellyfin.Api/Controllers/ApiKeyController.cs index 024a15349e..991f8cbf20 100644 --- a/Jellyfin.Api/Controllers/ApiKeyController.cs +++ b/Jellyfin.Api/Controllers/ApiKeyController.cs @@ -7,70 +7,69 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// Authentication controller. +/// +[Route("Auth")] +public class ApiKeyController : BaseJellyfinApiController { + private readonly IAuthenticationManager _authenticationManager; + /// - /// Authentication controller. + /// Initializes a new instance of the class. /// - [Route("Auth")] - public class ApiKeyController : BaseJellyfinApiController + /// Instance of interface. + public ApiKeyController(IAuthenticationManager authenticationManager) { - private readonly IAuthenticationManager _authenticationManager; + _authenticationManager = authenticationManager; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of interface. - public ApiKeyController(IAuthenticationManager authenticationManager) - { - _authenticationManager = authenticationManager; - } + /// + /// Get all keys. + /// + /// Api keys retrieved. + /// A with all keys. + [HttpGet("Keys")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task>> GetKeys() + { + var keys = await _authenticationManager.GetApiKeys().ConfigureAwait(false); - /// - /// Get all keys. - /// - /// Api keys retrieved. - /// A with all keys. - [HttpGet("Keys")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task>> GetKeys() - { - var keys = await _authenticationManager.GetApiKeys().ConfigureAwait(false); + return new QueryResult(keys); + } - return new QueryResult(keys); - } + /// + /// Create a new api key. + /// + /// Name of the app using the authentication key. + /// Api key created. + /// A . + [HttpPost("Keys")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task CreateKey([FromQuery, Required] string app) + { + await _authenticationManager.CreateApiKey(app).ConfigureAwait(false); - /// - /// Create a new api key. - /// - /// Name of the app using the authentication key. - /// Api key created. - /// A . - [HttpPost("Keys")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task CreateKey([FromQuery, Required] string app) - { - await _authenticationManager.CreateApiKey(app).ConfigureAwait(false); + return NoContent(); + } - return NoContent(); - } + /// + /// Remove an api key. + /// + /// The access token to delete. + /// Api key deleted. + /// A . + [HttpDelete("Keys/{key}")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task RevokeKey([FromRoute, Required] string key) + { + await _authenticationManager.DeleteApiKey(key).ConfigureAwait(false); - /// - /// Remove an api key. - /// - /// The access token to delete. - /// Api key deleted. - /// A . - [HttpDelete("Keys/{key}")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task RevokeKey([FromRoute, Required] string key) - { - await _authenticationManager.DeleteApiKey(key).ConfigureAwait(false); - - return NoContent(); - } + return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/ArtistsController.cs b/Jellyfin.Api/Controllers/ArtistsController.cs index c8ac2ed526..c9d2f67f92 100644 --- a/Jellyfin.Api/Controllers/ArtistsController.cs +++ b/Jellyfin.Api/Controllers/ArtistsController.cs @@ -1,7 +1,6 @@ using System; using System.ComponentModel.DataAnnotations; using System.Linq; -using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; @@ -17,464 +16,466 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// The artists controller. +/// +[Route("Artists")] +[Authorize] +public class ArtistsController : BaseJellyfinApiController { + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IDtoService _dtoService; + /// - /// The artists controller. + /// Initializes a new instance of the class. /// - [Route("Artists")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class ArtistsController : BaseJellyfinApiController + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + public ArtistsController( + ILibraryManager libraryManager, + IUserManager userManager, + IDtoService dtoService) { - private readonly ILibraryManager _libraryManager; - private readonly IUserManager _userManager; - private readonly IDtoService _dtoService; + _libraryManager = libraryManager; + _userManager = userManager; + _dtoService = dtoService; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - public ArtistsController( - ILibraryManager libraryManager, - IUserManager userManager, - IDtoService dtoService) + /// + /// Gets all artists from a given item, folder, or the entire library. + /// + /// Optional filter by minimum community rating. + /// Optional. The record index to start at. All items with a lower index will be dropped from the results. + /// Optional. The maximum number of records to return. + /// Optional. Search term. + /// Specify this to localize the search to a specific item or folder. Omit to use the root. + /// Optional. Specify additional fields of information to return in the output. + /// Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited. + /// Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited. + /// Optional. Specify additional filters to apply. + /// Optional filter by items that are marked as favorite, or not. + /// Optional filter by MediaType. Allows multiple, comma delimited. + /// Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited. + /// Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited. + /// Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited. + /// Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited. + /// Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited. + /// Optional, include user data. + /// Optional, the max number of images to return, per image type. + /// Optional. The image types to include in the output. + /// Optional. If specified, results will be filtered to include only those containing the specified person. + /// Optional. If specified, results will be filtered to include only those containing the specified person ids. + /// Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited. + /// Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited. + /// Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited. + /// User id. + /// Optional filter by items whose name is sorted equally or greater than a given input string. + /// Optional filter by items whose name is sorted equally than a given input string. + /// Optional filter by items whose name is equally or lesser than a given input string. + /// Optional. Specify one or more sort orders, comma delimited. + /// Sort Order - Ascending,Descending. + /// Optional, include image information in output. + /// Total record count. + /// Artists returned. + /// An containing the artists. + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> 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); } - /// - /// Gets all artists from a given item, folder, or the entire library. - /// - /// Optional filter by minimum community rating. - /// Optional. The record index to start at. All items with a lower index will be dropped from the results. - /// Optional. The maximum number of records to return. - /// Optional. Search term. - /// Specify this to localize the search to a specific item or folder. Omit to use the root. - /// Optional. Specify additional fields of information to return in the output. - /// Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited. - /// Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited. - /// Optional. Specify additional filters to apply. - /// Optional filter by items that are marked as favorite, or not. - /// Optional filter by MediaType. Allows multiple, comma delimited. - /// Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited. - /// Optional, include user data. - /// Optional, the max number of images to return, per image type. - /// Optional. The image types to include in the output. - /// Optional. If specified, results will be filtered to include only those containing the specified person. - /// Optional. If specified, results will be filtered to include only those containing the specified person ids. - /// Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited. - /// Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited. - /// User id. - /// Optional filter by items whose name is sorted equally or greater than a given input string. - /// Optional filter by items whose name is sorted equally than a given input string. - /// Optional filter by items whose name is equally or lesser than a given input string. - /// Optional. Specify one or more sort orders, comma delimited. - /// Sort Order - Ascending,Descending. - /// Optional, include image information in output. - /// Total record count. - /// Artists returned. - /// An containing the artists. - [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> 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) - { - if (parentItem is Folder) - { - query.AncestorIds = new[] { parentId.Value }; - } - else - { - query.ItemIds = new[] { parentId.Value }; - } + query.ItemIds = new[] { parentId.Value }; } - - // Studios - if (studios.Length != 0) - { - query.StudioIds = studios.Select(i => - { - try - { - return _libraryManager.GetStudio(i); - } - catch - { - return null; - } - }).Where(i => i is not null).Select(i => i!.Id).ToArray(); - } - - 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.GetArtists(query); - - var dtos = result.Items.Select(i => - { - var (baseItem, itemCounts) = i; - var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); - - if (includeItemTypes.Length != 0) - { - dto.ChildCount = itemCounts.ItemCount; - dto.ProgramCount = itemCounts.ProgramCount; - dto.SeriesCount = itemCounts.SeriesCount; - dto.EpisodeCount = itemCounts.EpisodeCount; - dto.MovieCount = itemCounts.MovieCount; - dto.TrailerCount = itemCounts.TrailerCount; - dto.AlbumCount = itemCounts.AlbumCount; - dto.SongCount = itemCounts.SongCount; - dto.ArtistCount = itemCounts.ArtistCount; - } - - return dto; - }); - - return new QueryResult( - query.StartIndex, - result.TotalRecordCount, - dtos.ToArray()); } - /// - /// Gets all album artists from a given item, folder, or the entire library. - /// - /// Optional filter by minimum community rating. - /// Optional. The record index to start at. All items with a lower index will be dropped from the results. - /// Optional. The maximum number of records to return. - /// Optional. Search term. - /// Specify this to localize the search to a specific item or folder. Omit to use the root. - /// Optional. Specify additional fields of information to return in the output. - /// Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited. - /// Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited. - /// Optional. Specify additional filters to apply. - /// Optional filter by items that are marked as favorite, or not. - /// Optional filter by MediaType. Allows multiple, comma delimited. - /// Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited. - /// Optional, include user data. - /// Optional, the max number of images to return, per image type. - /// Optional. The image types to include in the output. - /// Optional. If specified, results will be filtered to include only those containing the specified person. - /// Optional. If specified, results will be filtered to include only those containing the specified person ids. - /// Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited. - /// Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited. - /// User id. - /// Optional filter by items whose name is sorted equally or greater than a given input string. - /// Optional filter by items whose name is sorted equally than a given input string. - /// Optional filter by items whose name is equally or lesser than a given input string. - /// Optional. Specify one or more sort orders, comma delimited. - /// Sort Order - Ascending,Descending. - /// Optional, include image information in output. - /// Total record count. - /// Album artists returned. - /// An containing the album artists. - [HttpGet("AlbumArtists")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> 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) + // Studios + if (studios.Length != 0) { - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - - User? user = null; - BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId); - - if (userId.HasValue && !userId.Equals(default)) + query.StudioIds = studios.Select(i => { - user = _userManager.GetUserById(userId.Value); - } - - var query = new InternalItemsQuery(user) - { - 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) - { - if (parentItem is Folder) + try { - query.AncestorIds = new[] { parentId.Value }; + return _libraryManager.GetStudio(i); } - else + catch { - query.ItemIds = new[] { parentId.Value }; + return null; } - } - - // Studios - if (studios.Length != 0) - { - query.StudioIds = studios.Select(i => - { - try - { - return _libraryManager.GetStudio(i); - } - catch - { - return null; - } - }).Where(i => i is not null).Select(i => i!.Id).ToArray(); - } - - 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 dtos = result.Items.Select(i => - { - var (baseItem, itemCounts) = i; - var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); - - if (includeItemTypes.Length != 0) - { - dto.ChildCount = itemCounts.ItemCount; - dto.ProgramCount = itemCounts.ProgramCount; - dto.SeriesCount = itemCounts.SeriesCount; - dto.EpisodeCount = itemCounts.EpisodeCount; - dto.MovieCount = itemCounts.MovieCount; - dto.TrailerCount = itemCounts.TrailerCount; - dto.AlbumCount = itemCounts.AlbumCount; - dto.SongCount = itemCounts.SongCount; - dto.ArtistCount = itemCounts.ArtistCount; - } - - return dto; - }); - - return new QueryResult( - query.StartIndex, - result.TotalRecordCount, - dtos.ToArray()); + }).Where(i => i is not null).Select(i => i!.Id).ToArray(); } - /// - /// Gets an artist by name. - /// - /// Studio name. - /// Optional. Filter by user id, and attach user data. - /// Artist returned. - /// An containing the artist. - [HttpGet("{name}")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult GetArtistByName([FromRoute, Required] string name, [FromQuery] Guid? userId) + foreach (var filter in filters) { - var dtoOptions = new DtoOptions().AddClientFields(User); - - var item = _libraryManager.GetArtist(name, dtoOptions); - - if (userId.HasValue && !userId.Value.Equals(default)) + switch (filter) { - var user = _userManager.GetUserById(userId.Value); + 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 _dtoService.GetBaseItemDto(item, dtoOptions, user); + var result = _libraryManager.GetArtists(query); + + var dtos = result.Items.Select(i => + { + var (baseItem, itemCounts) = i; + var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); + + if (includeItemTypes.Length != 0) + { + dto.ChildCount = itemCounts.ItemCount; + dto.ProgramCount = itemCounts.ProgramCount; + dto.SeriesCount = itemCounts.SeriesCount; + dto.EpisodeCount = itemCounts.EpisodeCount; + dto.MovieCount = itemCounts.MovieCount; + dto.TrailerCount = itemCounts.TrailerCount; + dto.AlbumCount = itemCounts.AlbumCount; + dto.SongCount = itemCounts.SongCount; + dto.ArtistCount = itemCounts.ArtistCount; } - return _dtoService.GetBaseItemDto(item, dtoOptions); + return dto; + }); + + return new QueryResult( + query.StartIndex, + result.TotalRecordCount, + dtos.ToArray()); + } + + /// + /// Gets all album artists from a given item, folder, or the entire library. + /// + /// Optional filter by minimum community rating. + /// Optional. The record index to start at. All items with a lower index will be dropped from the results. + /// Optional. The maximum number of records to return. + /// Optional. Search term. + /// Specify this to localize the search to a specific item or folder. Omit to use the root. + /// Optional. Specify additional fields of information to return in the output. + /// Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited. + /// Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited. + /// Optional. Specify additional filters to apply. + /// Optional filter by items that are marked as favorite, or not. + /// Optional filter by MediaType. Allows multiple, comma delimited. + /// Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited. + /// Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited. + /// Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited. + /// Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited. + /// Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited. + /// Optional, include user data. + /// Optional, the max number of images to return, per image type. + /// Optional. The image types to include in the output. + /// Optional. If specified, results will be filtered to include only those containing the specified person. + /// Optional. If specified, results will be filtered to include only those containing the specified person ids. + /// Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited. + /// Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited. + /// Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited. + /// User id. + /// Optional filter by items whose name is sorted equally or greater than a given input string. + /// Optional filter by items whose name is sorted equally than a given input string. + /// Optional filter by items whose name is equally or lesser than a given input string. + /// Optional. Specify one or more sort orders, comma delimited. + /// Sort Order - Ascending,Descending. + /// Optional, include image information in output. + /// Total record count. + /// Album artists returned. + /// An containing the album artists. + [HttpGet("AlbumArtists")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> 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); + + User? user = null; + BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId); + + if (!userId.Value.Equals(default)) + { + user = _userManager.GetUserById(userId.Value); } + + var query = new InternalItemsQuery(user) + { + 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) + { + if (parentItem is Folder) + { + query.AncestorIds = new[] { parentId.Value }; + } + else + { + query.ItemIds = new[] { parentId.Value }; + } + } + + // Studios + if (studios.Length != 0) + { + query.StudioIds = studios.Select(i => + { + try + { + return _libraryManager.GetStudio(i); + } + catch + { + return null; + } + }).Where(i => i is not null).Select(i => i!.Id).ToArray(); + } + + 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 dtos = result.Items.Select(i => + { + var (baseItem, itemCounts) = i; + var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); + + if (includeItemTypes.Length != 0) + { + dto.ChildCount = itemCounts.ItemCount; + dto.ProgramCount = itemCounts.ProgramCount; + dto.SeriesCount = itemCounts.SeriesCount; + dto.EpisodeCount = itemCounts.EpisodeCount; + dto.MovieCount = itemCounts.MovieCount; + dto.TrailerCount = itemCounts.TrailerCount; + dto.AlbumCount = itemCounts.AlbumCount; + dto.SongCount = itemCounts.SongCount; + dto.ArtistCount = itemCounts.ArtistCount; + } + + return dto; + }); + + return new QueryResult( + query.StartIndex, + result.TotalRecordCount, + dtos.ToArray()); + } + + /// + /// Gets an artist by name. + /// + /// Studio name. + /// Optional. Filter by user id, and attach user data. + /// Artist returned. + /// An containing the artist. + [HttpGet("{name}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult GetArtistByName([FromRoute, Required] string name, [FromQuery] Guid? userId) + { + userId = RequestHelpers.GetUserId(User, userId); + var dtoOptions = new DtoOptions().AddClientFields(User); + + var item = _libraryManager.GetArtist(name, dtoOptions); + + if (!userId.Value.Equals(default)) + { + var user = _userManager.GetUserById(userId.Value); + + return _dtoService.GetBaseItemDto(item, dtoOptions, user); + } + + return _dtoService.GetBaseItemDto(item, dtoOptions); } } diff --git a/Jellyfin.Api/Controllers/AudioController.cs b/Jellyfin.Api/Controllers/AudioController.cs index 94f7a7b827..968193a6f8 100644 --- a/Jellyfin.Api/Controllers/AudioController.cs +++ b/Jellyfin.Api/Controllers/AudioController.cs @@ -10,355 +10,354 @@ using MediaBrowser.Model.Dlna; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// The audio controller. +/// +// 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; + /// - /// The audio controller. + /// Initializes a new instance of the class. /// - // TODO: In order to authenticate this in the future, Dlna playback will require updating - public class AudioController : BaseJellyfinApiController + /// Instance of . + public AudioController(AudioHelper audioHelper) { - private readonly AudioHelper _audioHelper; + _audioHelper = audioHelper; + } - private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Progressive; - - /// - /// Initializes a new instance of the class. - /// - /// Instance of . - public AudioController(AudioHelper audioHelper) + /// + /// Gets an audio stream. + /// + /// The item id. + /// The audio container. + /// 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. + /// The streaming parameters. + /// The tag. + /// Optional. The dlna device profile id to utilize. + /// The play session id. + /// The segment container. + /// The segment length. + /// The minimum number of segments. + /// The media version id, if playing an alternate version. + /// The device id of the client requesting. Used to stop encoding processes when needed. + /// 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. + /// Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true. + /// Whether or not to allow copying of the video stream url. + /// Whether or not to allow copying of the audio stream url. + /// Optional. Whether to break on non key frames. + /// Optional. Specify a specific audio sample rate, e.g. 44100. + /// Optional. The maximum audio bit depth. + /// Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults. + /// Optional. Specify a specific number of audio channels to encode to, e.g. 2. + /// Optional. Specify a maximum number of audio channels to encode to, e.g. 2. + /// Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high. + /// Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1. + /// Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements. + /// Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements. + /// Whether or not to copy timestamps when transcoding with an offset. Defaults to false. + /// Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms. + /// Optional. The fixed horizontal resolution of the encoded video. + /// Optional. The fixed vertical resolution of the encoded video. + /// Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults. + /// Optional. The index of the subtitle stream to use. If omitted no subtitles will be used. + /// Optional. Specify the subtitle delivery method. + /// Optional. + /// Optional. The maximum video bit depth. + /// Optional. Whether to require avc. + /// Optional. Whether to deinterlace the video. + /// Optional. Whether to require a non anamorphic stream. + /// Optional. The maximum number of audio channels to transcode. + /// Optional. The limit of how many cpu cores to use. + /// The live stream id. + /// Optional. Whether to enable the MpegtsM2Ts mode. + /// 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. + /// Optional. Specify a subtitle codec to encode to. + /// Optional. The transcoding reason. + /// Optional. The index of the audio stream to use. If omitted the first audio stream will be used. + /// Optional. The index of the video stream to use. If omitted the first video stream will be used. + /// Optional. The . + /// Optional. The streaming options. + /// Audio stream returned. + /// A containing the audio file. + [HttpGet("{itemId}/stream", Name = "GetAudioStream")] + [HttpHead("{itemId}/stream", Name = "HeadAudioStream")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesAudioFile] + public async Task 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? streamOptions) + { + StreamingRequestDto streamingRequest = new StreamingRequestDto { - _audioHelper = audioHelper; - } + 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 + }; - /// - /// Gets an audio stream. - /// - /// The item id. - /// The audio container. - /// 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. - /// The streaming parameters. - /// The tag. - /// Optional. The dlna device profile id to utilize. - /// The play session id. - /// The segment container. - /// The segment length. - /// The minimum number of segments. - /// The media version id, if playing an alternate version. - /// The device id of the client requesting. Used to stop encoding processes when needed. - /// 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. - /// Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true. - /// Whether or not to allow copying of the video stream url. - /// Whether or not to allow copying of the audio stream url. - /// Optional. Whether to break on non key frames. - /// Optional. Specify a specific audio sample rate, e.g. 44100. - /// Optional. The maximum audio bit depth. - /// Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults. - /// Optional. Specify a specific number of audio channels to encode to, e.g. 2. - /// Optional. Specify a maximum number of audio channels to encode to, e.g. 2. - /// Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high. - /// Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1. - /// Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements. - /// Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements. - /// Whether or not to copy timestamps when transcoding with an offset. Defaults to false. - /// Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms. - /// Optional. The fixed horizontal resolution of the encoded video. - /// Optional. The fixed vertical resolution of the encoded video. - /// Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults. - /// Optional. The index of the subtitle stream to use. If omitted no subtitles will be used. - /// Optional. Specify the subtitle delivery method. - /// Optional. - /// Optional. The maximum video bit depth. - /// Optional. Whether to require avc. - /// Optional. Whether to deinterlace the video. - /// Optional. Whether to require a non anamorphic stream. - /// Optional. The maximum number of audio channels to transcode. - /// Optional. The limit of how many cpu cores to use. - /// The live stream id. - /// Optional. Whether to enable the MpegtsM2Ts mode. - /// 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. - /// Optional. Specify a subtitle codec to encode to. - /// Optional. The transcoding reason. - /// Optional. The index of the audio stream to use. If omitted the first audio stream will be used. - /// Optional. The index of the video stream to use. If omitted the first video stream will be used. - /// Optional. The . - /// Optional. The streaming options. - /// Audio stream returned. - /// A containing the audio file. - [HttpGet("{itemId}/stream", Name = "GetAudioStream")] - [HttpHead("{itemId}/stream", Name = "HeadAudioStream")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesAudioFile] - public async Task 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? streamOptions) + return await _audioHelper.GetAudioStream(_transcodingJobType, streamingRequest).ConfigureAwait(false); + } + + /// + /// Gets an audio stream. + /// + /// The item id. + /// The audio container. + /// 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. + /// The streaming parameters. + /// The tag. + /// Optional. The dlna device profile id to utilize. + /// The play session id. + /// The segment container. + /// The segment length. + /// The minimum number of segments. + /// The media version id, if playing an alternate version. + /// The device id of the client requesting. Used to stop encoding processes when needed. + /// 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. + /// Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true. + /// Whether or not to allow copying of the video stream url. + /// Whether or not to allow copying of the audio stream url. + /// Optional. Whether to break on non key frames. + /// Optional. Specify a specific audio sample rate, e.g. 44100. + /// Optional. The maximum audio bit depth. + /// Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults. + /// Optional. Specify a specific number of audio channels to encode to, e.g. 2. + /// Optional. Specify a maximum number of audio channels to encode to, e.g. 2. + /// Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high. + /// Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1. + /// Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements. + /// Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements. + /// Whether or not to copy timestamps when transcoding with an offset. Defaults to false. + /// Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms. + /// Optional. The fixed horizontal resolution of the encoded video. + /// Optional. The fixed vertical resolution of the encoded video. + /// Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults. + /// Optional. The index of the subtitle stream to use. If omitted no subtitles will be used. + /// Optional. Specify the subtitle delivery method. + /// Optional. + /// Optional. The maximum video bit depth. + /// Optional. Whether to require avc. + /// Optional. Whether to deinterlace the video. + /// Optional. Whether to require a non anamporphic stream. + /// Optional. The maximum number of audio channels to transcode. + /// Optional. The limit of how many cpu cores to use. + /// The live stream id. + /// Optional. Whether to enable the MpegtsM2Ts mode. + /// 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. + /// Optional. Specify a subtitle codec to encode to. + /// Optional. The transcoding reason. + /// Optional. The index of the audio stream to use. If omitted the first audio stream will be used. + /// Optional. The index of the video stream to use. If omitted the first video stream will be used. + /// Optional. The . + /// Optional. The streaming options. + /// Audio stream returned. + /// A containing the audio file. + [HttpGet("{itemId}/stream.{container}", Name = "GetAudioStreamByContainer")] + [HttpHead("{itemId}/stream.{container}", Name = "HeadAudioStreamByContainer")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesAudioFile] + public async Task 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? 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); - } - - /// - /// Gets an audio stream. - /// - /// The item id. - /// The audio container. - /// 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. - /// The streaming parameters. - /// The tag. - /// Optional. The dlna device profile id to utilize. - /// The play session id. - /// The segment container. - /// The segment length. - /// The minimum number of segments. - /// The media version id, if playing an alternate version. - /// The device id of the client requesting. Used to stop encoding processes when needed. - /// 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. - /// Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true. - /// Whether or not to allow copying of the video stream url. - /// Whether or not to allow copying of the audio stream url. - /// Optional. Whether to break on non key frames. - /// Optional. Specify a specific audio sample rate, e.g. 44100. - /// Optional. The maximum audio bit depth. - /// Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults. - /// Optional. Specify a specific number of audio channels to encode to, e.g. 2. - /// Optional. Specify a maximum number of audio channels to encode to, e.g. 2. - /// Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high. - /// Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1. - /// Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements. - /// Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements. - /// Whether or not to copy timestamps when transcoding with an offset. Defaults to false. - /// Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms. - /// Optional. The fixed horizontal resolution of the encoded video. - /// Optional. The fixed vertical resolution of the encoded video. - /// Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults. - /// Optional. The index of the subtitle stream to use. If omitted no subtitles will be used. - /// Optional. Specify the subtitle delivery method. - /// Optional. - /// Optional. The maximum video bit depth. - /// Optional. Whether to require avc. - /// Optional. Whether to deinterlace the video. - /// Optional. Whether to require a non anamporphic stream. - /// Optional. The maximum number of audio channels to transcode. - /// Optional. The limit of how many cpu cores to use. - /// The live stream id. - /// Optional. Whether to enable the MpegtsM2Ts mode. - /// 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. - /// Optional. Specify a subtitle codec to encode to. - /// Optional. The transcoding reason. - /// Optional. The index of the audio stream to use. If omitted the first audio stream will be used. - /// Optional. The index of the video stream to use. If omitted the first video stream will be used. - /// Optional. The . - /// Optional. The streaming options. - /// Audio stream returned. - /// A containing the audio file. - [HttpGet("{itemId}/stream.{container}", Name = "GetAudioStreamByContainer")] - [HttpHead("{itemId}/stream.{container}", Name = "HeadAudioStreamByContainer")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesAudioFile] - public async Task 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? streamOptions) - { - 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 - }; - - return await _audioHelper.GetAudioStream(_transcodingJobType, streamingRequest).ConfigureAwait(false); - } + return await _audioHelper.GetAudioStream(_transcodingJobType, streamingRequest).ConfigureAwait(false); } } diff --git a/Jellyfin.Api/Controllers/BrandingController.cs b/Jellyfin.Api/Controllers/BrandingController.cs index d3ea412015..3c2c4b4dbd 100644 --- a/Jellyfin.Api/Controllers/BrandingController.cs +++ b/Jellyfin.Api/Controllers/BrandingController.cs @@ -4,54 +4,53 @@ using MediaBrowser.Model.Branding; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// Branding controller. +/// +public class BrandingController : BaseJellyfinApiController { + private readonly IServerConfigurationManager _serverConfigurationManager; + /// - /// Branding controller. + /// Initializes a new instance of the class. /// - public class BrandingController : BaseJellyfinApiController + /// Instance of the interface. + public BrandingController(IServerConfigurationManager serverConfigurationManager) { - private readonly IServerConfigurationManager _serverConfigurationManager; + _serverConfigurationManager = serverConfigurationManager; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - public BrandingController(IServerConfigurationManager serverConfigurationManager) - { - _serverConfigurationManager = serverConfigurationManager; - } + /// + /// Gets branding configuration. + /// + /// Branding configuration returned. + /// An containing the branding configuration. + [HttpGet("Configuration")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult GetBrandingOptions() + { + return _serverConfigurationManager.GetConfiguration("branding"); + } - /// - /// Gets branding configuration. - /// - /// Branding configuration returned. - /// An containing the branding configuration. - [HttpGet("Configuration")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult GetBrandingOptions() - { - return _serverConfigurationManager.GetConfiguration("branding"); - } - - /// - /// Gets branding css. - /// - /// Branding css returned. - /// No branding css configured. - /// - /// An containing the branding css if exist, - /// or a if the css is not configured. - /// - [HttpGet("Css")] - [HttpGet("Css.css", Name = "GetBrandingCss_2")] - [Produces("text/css")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult GetBrandingCss() - { - var options = _serverConfigurationManager.GetConfiguration("branding"); - return options.CustomCss ?? string.Empty; - } + /// + /// Gets branding css. + /// + /// Branding css returned. + /// No branding css configured. + /// + /// An containing the branding css if exist, + /// or a if the css is not configured. + /// + [HttpGet("Css")] + [HttpGet("Css.css", Name = "GetBrandingCss_2")] + [Produces("text/css")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult GetBrandingCss() + { + var options = _serverConfigurationManager.GetConfiguration("branding"); + return options.CustomCss ?? string.Empty; } } diff --git a/Jellyfin.Api/Controllers/ChannelsController.cs b/Jellyfin.Api/Controllers/ChannelsController.cs index d5b589a3fa..11c4ac3768 100644 --- a/Jellyfin.Api/Controllers/ChannelsController.cs +++ b/Jellyfin.Api/Controllers/ChannelsController.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Api.Constants; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; @@ -18,234 +17,236 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// Channels Controller. +/// +[Authorize] +public class ChannelsController : BaseJellyfinApiController { + private readonly IChannelManager _channelManager; + private readonly IUserManager _userManager; + /// - /// Channels Controller. + /// Initializes a new instance of the class. /// - [Authorize(Policy = Policies.DefaultAuthorization)] - public class ChannelsController : BaseJellyfinApiController + /// Instance of the interface. + /// Instance of the interface. + public ChannelsController(IChannelManager channelManager, IUserManager userManager) { - private readonly IChannelManager _channelManager; - private readonly IUserManager _userManager; + _channelManager = channelManager; + _userManager = userManager; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - public ChannelsController(IChannelManager channelManager, IUserManager userManager) + /// + /// Gets available channels. + /// + /// User Id to filter by. Use to not filter by user. + /// Optional. The record index to start at. All items with a lower index will be dropped from the results. + /// Optional. The maximum number of records to return. + /// Optional. Filter by channels that support getting latest items. + /// Optional. Filter by channels that support media deletion. + /// Optional. Filter by channels that are favorite. + /// Channels returned. + /// An containing the channels. + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task>> 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); + } - /// - /// Gets available channels. - /// - /// User Id to filter by. Use to not filter by user. - /// Optional. The record index to start at. All items with a lower index will be dropped from the results. - /// Optional. The maximum number of records to return. - /// Optional. Filter by channels that support getting latest items. - /// Optional. Filter by channels that support media deletion. - /// Optional. Filter by channels that are favorite. - /// Channels returned. - /// An containing the channels. - [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> GetChannels( - [FromQuery] Guid? userId, - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery] bool? supportsLatestItems, - [FromQuery] bool? supportsMediaDeletion, - [FromQuery] bool? isFavorite) + /// + /// Get all channel features. + /// + /// All channel features returned. + /// An containing the channel features. + [HttpGet("Features")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> GetAllChannelFeatures() + { + return _channelManager.GetAllChannelFeatures(); + } + + /// + /// Get channel features. + /// + /// Channel id. + /// Channel features returned. + /// An containing the channel features. + [HttpGet("{channelId}/Features")] + public ActionResult GetChannelFeatures([FromRoute, Required] Guid channelId) + { + return _channelManager.GetChannelFeatures(channelId); + } + + /// + /// Get channel items. + /// + /// Channel Id. + /// Optional. Folder Id. + /// Optional. User Id. + /// Optional. The record index to start at. All items with a lower index will be dropped from the results. + /// Optional. The maximum number of records to return. + /// Optional. Sort Order - Ascending,Descending. + /// Optional. Specify additional filters to apply. + /// 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. + /// Optional. Specify additional fields of information to return in the output. + /// Channel items returned. + /// + /// A representing the request to get the channel items. + /// The task result contains an containing the channel items. + /// + [HttpGet("{channelId}/Items")] + public async Task>> 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); + + var query = new InternalItemsQuery(user) { - return _channelManager.GetChannels(new ChannelQuery + 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) + { + switch (filter) { - Limit = limit, - StartIndex = startIndex, - UserId = userId ?? Guid.Empty, - SupportsLatestItems = supportsLatestItems, - SupportsMediaDeletion = supportsMediaDeletion, - IsFavorite = isFavorite - }); - } - - /// - /// Get all channel features. - /// - /// All channel features returned. - /// An containing the channel features. - [HttpGet("Features")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> GetAllChannelFeatures() - { - return _channelManager.GetAllChannelFeatures(); - } - - /// - /// Get channel features. - /// - /// Channel id. - /// Channel features returned. - /// An containing the channel features. - [HttpGet("{channelId}/Features")] - public ActionResult GetChannelFeatures([FromRoute, Required] Guid channelId) - { - return _channelManager.GetChannelFeatures(channelId); - } - - /// - /// Get channel items. - /// - /// Channel Id. - /// Optional. Folder Id. - /// Optional. User Id. - /// Optional. The record index to start at. All items with a lower index will be dropped from the results. - /// Optional. The maximum number of records to return. - /// Optional. Sort Order - Ascending,Descending. - /// Optional. Specify additional filters to apply. - /// 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. - /// Optional. Specify additional fields of information to return in the output. - /// Channel items returned. - /// - /// A representing the request to get the channel items. - /// The task result contains an containing the channel items. - /// - [HttpGet("{channelId}/Items")] - public async Task>> 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 user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - - 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) - { - 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); } - /// - /// Gets latest channel items. - /// - /// Optional. User Id. - /// Optional. The record index to start at. All items with a lower index will be dropped from the results. - /// Optional. The maximum number of records to return. - /// Optional. Specify additional filters to apply. - /// Optional. Specify additional fields of information to return in the output. - /// Optional. Specify one or more channel id's, comma delimited. - /// Latest channel items returned. - /// - /// A representing the request to get the latest channel items. - /// The task result contains an containing the latest channel items. - /// - [HttpGet("Items/Latest")] - public async Task>> 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) + return await _channelManager.GetChannelItems(query, CancellationToken.None).ConfigureAwait(false); + } + + /// + /// Gets latest channel items. + /// + /// Optional. User Id. + /// Optional. The record index to start at. All items with a lower index will be dropped from the results. + /// Optional. The maximum number of records to return. + /// Optional. Specify additional filters to apply. + /// Optional. Specify additional fields of information to return in the output. + /// Optional. Specify one or more channel id's, comma delimited. + /// Latest channel items returned. + /// + /// A representing the request to get the latest channel items. + /// The task result contains an containing the latest channel items. + /// + [HttpGet("Items/Latest")] + public async Task>> 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); + + var query = new InternalItemsQuery(user) { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); + Limit = limit, + StartIndex = startIndex, + ChannelIds = channelIds, + DtoOptions = new DtoOptions { Fields = fields } + }; - var query = new InternalItemsQuery(user) + foreach (var filter in filters) + { + switch (filter) { - Limit = limit, - StartIndex = startIndex, - ChannelIds = channelIds, - DtoOptions = new DtoOptions { Fields = fields } - }; - - foreach (var filter in filters) - { - switch (filter) - { - case ItemFilter.IsFolder: - query.IsFolder = true; - break; - case ItemFilter.IsNotFolder: - query.IsFolder = false; - break; - case ItemFilter.IsUnplayed: - query.IsPlayed = false; - break; - case ItemFilter.IsPlayed: - query.IsPlayed = true; - break; - case ItemFilter.IsFavorite: - query.IsFavorite = true; - break; - case ItemFilter.IsResumable: - query.IsResumable = true; - break; - case ItemFilter.Likes: - query.IsLiked = true; - break; - case ItemFilter.Dislikes: - query.IsLiked = false; - break; - case ItemFilter.IsFavoriteOrLikes: - query.IsFavoriteOrLiked = true; - break; - } + case ItemFilter.IsFolder: + query.IsFolder = true; + break; + case ItemFilter.IsNotFolder: + query.IsFolder = false; + break; + case ItemFilter.IsUnplayed: + query.IsPlayed = false; + break; + case ItemFilter.IsPlayed: + query.IsPlayed = true; + break; + case ItemFilter.IsFavorite: + query.IsFavorite = true; + break; + case ItemFilter.IsResumable: + query.IsResumable = true; + break; + case ItemFilter.Likes: + query.IsLiked = true; + break; + case ItemFilter.Dislikes: + query.IsLiked = false; + break; + case ItemFilter.IsFavoriteOrLikes: + query.IsFavoriteOrLiked = true; + break; } - - return await _channelManager.GetLatestChannelItems(query, CancellationToken.None).ConfigureAwait(false); } + + return await _channelManager.GetLatestChannelItems(query, CancellationToken.None).ConfigureAwait(false); } } diff --git a/Jellyfin.Api/Controllers/ClientLogController.cs b/Jellyfin.Api/Controllers/ClientLogController.cs index ed073a687e..2c5dbacbbe 100644 --- a/Jellyfin.Api/Controllers/ClientLogController.cs +++ b/Jellyfin.Api/Controllers/ClientLogController.cs @@ -1,9 +1,7 @@ using System.Net.Mime; using System.Threading.Tasks; using Jellyfin.Api.Attributes; -using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; -using Jellyfin.Api.Helpers; using Jellyfin.Api.Models.ClientLogDtos; using MediaBrowser.Controller.ClientEvent; using MediaBrowser.Controller.Configuration; @@ -11,71 +9,70 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// Client log controller. +/// +[Authorize] +public class ClientLogController : BaseJellyfinApiController { + private const int MaxDocumentSize = 1_000_000; + private readonly IClientEventLogger _clientEventLogger; + private readonly IServerConfigurationManager _serverConfigurationManager; + /// - /// Client log controller. + /// Initializes a new instance of the class. /// - [Authorize(Policy = Policies.DefaultAuthorization)] - public class ClientLogController : BaseJellyfinApiController + /// Instance of the interface. + /// Instance of the interface. + 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; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - public ClientLogController( - IClientEventLogger clientEventLogger, - IServerConfigurationManager serverConfigurationManager) + /// + /// Upload a document. + /// + /// Document saved. + /// Event logging disabled. + /// Upload size too large. + /// Create response. + [HttpPost("Document")] + [ProducesResponseType(typeof(ClientLogDocumentResponseDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status413PayloadTooLarge)] + [AcceptsFile(MediaTypeNames.Text.Plain)] + [RequestSizeLimit(MaxDocumentSize)] + public async Task> LogFile() + { + if (!_serverConfigurationManager.Configuration.AllowClientLogUpload) { - _clientEventLogger = clientEventLogger; - _serverConfigurationManager = serverConfigurationManager; + return Forbid(); } - /// - /// Upload a document. - /// - /// Document saved. - /// Event logging disabled. - /// Upload size too large. - /// Create response. - [HttpPost("Document")] - [ProducesResponseType(typeof(ClientLogDocumentResponseDto), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status413PayloadTooLarge)] - [AcceptsFile(MediaTypeNames.Text.Plain)] - [RequestSizeLimit(MaxDocumentSize)] - public async Task> LogFile() + if (Request.ContentLength > MaxDocumentSize) { - if (!_serverConfigurationManager.Configuration.AllowClientLogUpload) - { - return Forbid(); - } - - if (Request.ContentLength > MaxDocumentSize) - { - // Manually validate to return proper status code. - return StatusCode(StatusCodes.Status413PayloadTooLarge, $"Payload must be less than {MaxDocumentSize:N0} bytes"); - } - - var (clientName, clientVersion) = GetRequestInformation(); - var fileName = await _clientEventLogger.WriteDocumentAsync(clientName, clientVersion, Request.Body) - .ConfigureAwait(false); - return Ok(new ClientLogDocumentResponseDto(fileName)); + // Manually validate to return proper status code. + return StatusCode(StatusCodes.Status413PayloadTooLarge, $"Payload must be less than {MaxDocumentSize:N0} bytes"); } - private (string ClientName, string ClientVersion) GetRequestInformation() - { - var clientName = HttpContext.User.GetClient() ?? "unknown-client"; - var clientVersion = HttpContext.User.GetIsApiKey() - ? "apikey" - : HttpContext.User.GetVersion() ?? "unknown-version"; + var (clientName, clientVersion) = GetRequestInformation(); + var fileName = await _clientEventLogger.WriteDocumentAsync(clientName, clientVersion, Request.Body) + .ConfigureAwait(false); + return Ok(new ClientLogDocumentResponseDto(fileName)); + } - return (clientName, clientVersion); - } + private (string ClientName, string ClientVersion) GetRequestInformation() + { + var clientName = HttpContext.User.GetClient() ?? "unknown-client"; + var clientVersion = HttpContext.User.GetIsApiKey() + ? "apikey" + : HttpContext.User.GetVersion() ?? "unknown-version"; + + return (clientName, clientVersion); } } diff --git a/Jellyfin.Api/Controllers/CollectionController.cs b/Jellyfin.Api/Controllers/CollectionController.cs index effc9ed7aa..2db04afb80 100644 --- a/Jellyfin.Api/Controllers/CollectionController.cs +++ b/Jellyfin.Api/Controllers/CollectionController.cs @@ -11,101 +11,100 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// The collection controller. +/// +[Route("Collections")] +[Authorize(Policy = Policies.CollectionManagement)] +public class CollectionController : BaseJellyfinApiController { + private readonly ICollectionManager _collectionManager; + private readonly IDtoService _dtoService; + /// - /// The collection controller. + /// Initializes a new instance of the class. /// - [Route("Collections")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class CollectionController : BaseJellyfinApiController + /// Instance of interface. + /// Instance of interface. + public CollectionController( + ICollectionManager collectionManager, + IDtoService dtoService) { - private readonly ICollectionManager _collectionManager; - private readonly IDtoService _dtoService; + _collectionManager = collectionManager; + _dtoService = dtoService; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of interface. - /// Instance of interface. - public CollectionController( - ICollectionManager collectionManager, - IDtoService dtoService) + /// + /// Creates a new collection. + /// + /// The name of the collection. + /// Item Ids to add to the collection. + /// Optional. Create the collection within a specific folder. + /// Whether or not to lock the new collection. + /// Collection created. + /// A with information about the new collection. + [HttpPost] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> CreateCollection( + [FromQuery] string? name, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] ids, + [FromQuery] Guid? parentId, + [FromQuery] bool isLocked = false) + { + var userId = User.GetUserId(); + + var item = await _collectionManager.CreateCollectionAsync(new CollectionCreationOptions { - _collectionManager = collectionManager; - _dtoService = dtoService; - } + IsLocked = isLocked, + Name = name, + ParentId = parentId, + ItemIdList = ids, + UserIds = new[] { userId } + }).ConfigureAwait(false); - /// - /// Creates a new collection. - /// - /// The name of the collection. - /// Item Ids to add to the collection. - /// Optional. Create the collection within a specific folder. - /// Whether or not to lock the new collection. - /// Collection created. - /// A with information about the new collection. - [HttpPost] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> CreateCollection( - [FromQuery] string? name, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] ids, - [FromQuery] Guid? parentId, - [FromQuery] bool isLocked = false) + var dtoOptions = new DtoOptions().AddClientFields(User); + + var dto = _dtoService.GetBaseItemDto(item, dtoOptions); + + return new CollectionCreationResult { - var userId = User.GetUserId(); + Id = dto.Id + }; + } - var item = await _collectionManager.CreateCollectionAsync(new CollectionCreationOptions - { - IsLocked = isLocked, - Name = name, - ParentId = parentId, - ItemIdList = ids, - UserIds = new[] { userId } - }).ConfigureAwait(false); + /// + /// Adds items to a collection. + /// + /// The collection id. + /// Item ids, comma delimited. + /// Items added to collection. + /// A indicating success. + [HttpPost("{collectionId}/Items")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task AddToCollection( + [FromRoute, Required] Guid collectionId, + [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) + { + await _collectionManager.AddToCollectionAsync(collectionId, ids).ConfigureAwait(true); + return NoContent(); + } - var dtoOptions = new DtoOptions().AddClientFields(User); - - var dto = _dtoService.GetBaseItemDto(item, dtoOptions); - - return new CollectionCreationResult - { - Id = dto.Id - }; - } - - /// - /// Adds items to a collection. - /// - /// The collection id. - /// Item ids, comma delimited. - /// Items added to collection. - /// A indicating success. - [HttpPost("{collectionId}/Items")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task AddToCollection( - [FromRoute, Required] Guid collectionId, - [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) - { - await _collectionManager.AddToCollectionAsync(collectionId, ids).ConfigureAwait(true); - return NoContent(); - } - - /// - /// Removes items from a collection. - /// - /// The collection id. - /// Item ids, comma delimited. - /// Items removed from collection. - /// A indicating success. - [HttpDelete("{collectionId}/Items")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task RemoveFromCollection( - [FromRoute, Required] Guid collectionId, - [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) - { - await _collectionManager.RemoveFromCollectionAsync(collectionId, ids).ConfigureAwait(false); - return NoContent(); - } + /// + /// Removes items from a collection. + /// + /// The collection id. + /// Item ids, comma delimited. + /// Items removed from collection. + /// A indicating success. + [HttpDelete("{collectionId}/Items")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task RemoveFromCollection( + [FromRoute, Required] Guid collectionId, + [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) + { + await _collectionManager.RemoveFromCollectionAsync(collectionId, ids).ConfigureAwait(false); + return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/ConfigurationController.cs b/Jellyfin.Api/Controllers/ConfigurationController.cs index a00ac1b0af..9007dfc410 100644 --- a/Jellyfin.Api/Controllers/ConfigurationController.cs +++ b/Jellyfin.Api/Controllers/ConfigurationController.cs @@ -13,124 +13,123 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// Configuration Controller. +/// +[Route("System")] +[Authorize] +public class ConfigurationController : BaseJellyfinApiController { + private readonly IServerConfigurationManager _configurationManager; + private readonly IMediaEncoder _mediaEncoder; + + private readonly JsonSerializerOptions _serializerOptions = JsonDefaults.Options; + /// - /// Configuration Controller. + /// Initializes a new instance of the class. /// - [Route("System")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class ConfigurationController : BaseJellyfinApiController + /// Instance of the interface. + /// Instance of the interface. + public ConfigurationController( + IServerConfigurationManager configurationManager, + IMediaEncoder mediaEncoder) { - private readonly IServerConfigurationManager _configurationManager; - private readonly IMediaEncoder _mediaEncoder; + _configurationManager = configurationManager; + _mediaEncoder = mediaEncoder; + } - private readonly JsonSerializerOptions _serializerOptions = JsonDefaults.Options; + /// + /// Gets application configuration. + /// + /// Application configuration returned. + /// Application configuration. + [HttpGet("Configuration")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult GetConfiguration() + { + return _configurationManager.Configuration; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - public ConfigurationController( - IServerConfigurationManager configurationManager, - IMediaEncoder mediaEncoder) + /// + /// Updates application configuration. + /// + /// Configuration. + /// Configuration updated. + /// Update status. + [HttpPost("Configuration")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult UpdateConfiguration([FromBody, Required] ServerConfiguration configuration) + { + _configurationManager.ReplaceConfiguration(configuration); + return NoContent(); + } + + /// + /// Gets a named configuration. + /// + /// Configuration key. + /// Configuration returned. + /// Configuration. + [HttpGet("Configuration/{key}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesFile(MediaTypeNames.Application.Json)] + public ActionResult GetNamedConfiguration([FromRoute, Required] string key) + { + return _configurationManager.GetConfiguration(key); + } + + /// + /// Updates named configuration. + /// + /// Configuration key. + /// Configuration. + /// Named configuration updated. + /// Update status. + [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) { - _configurationManager = configurationManager; - _mediaEncoder = mediaEncoder; + throw new ArgumentException("Body doesn't contain a valid configuration"); } - /// - /// Gets application configuration. - /// - /// Application configuration returned. - /// Application configuration. - [HttpGet("Configuration")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult GetConfiguration() - { - return _configurationManager.Configuration; - } + _configurationManager.SaveConfiguration(key, deserializedConfiguration); + return NoContent(); + } - /// - /// Updates application configuration. - /// - /// Configuration. - /// Configuration updated. - /// Update status. - [HttpPost("Configuration")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult UpdateConfiguration([FromBody, Required] ServerConfiguration configuration) - { - _configurationManager.ReplaceConfiguration(configuration); - return NoContent(); - } + /// + /// Gets a default MetadataOptions object. + /// + /// Metadata options returned. + /// Default MetadataOptions. + [HttpGet("Configuration/MetadataOptions/Default")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult GetDefaultMetadataOptions() + { + return new MetadataOptions(); + } - /// - /// Gets a named configuration. - /// - /// Configuration key. - /// Configuration returned. - /// Configuration. - [HttpGet("Configuration/{key}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesFile(MediaTypeNames.Application.Json)] - public ActionResult GetNamedConfiguration([FromRoute, Required] string key) - { - return _configurationManager.GetConfiguration(key); - } - - /// - /// Updates named configuration. - /// - /// Configuration key. - /// Configuration. - /// Named configuration updated. - /// Update status. - [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(); - } - - /// - /// Gets a default MetadataOptions object. - /// - /// Metadata options returned. - /// Default MetadataOptions. - [HttpGet("Configuration/MetadataOptions/Default")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult GetDefaultMetadataOptions() - { - return new MetadataOptions(); - } - - /// - /// Updates the path to the media encoder. - /// - /// Media encoder path form body. - /// Media encoder path updated. - /// Status. - [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(); - } + /// + /// Updates the path to the media encoder. + /// + /// Media encoder path form body. + /// Media encoder path updated. + /// Status. + [HttpPost("MediaEncoder/Path")] + [Authorize(Policy = Policies.FirstTimeSetupOrElevated)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult UpdateMediaEncoderPath([FromBody, Required] MediaEncoderPathDto mediaEncoderPath) + { + _mediaEncoder.UpdateEncoderPath(mediaEncoderPath.Path, mediaEncoderPath.PathType); + return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/DashboardController.cs b/Jellyfin.Api/Controllers/DashboardController.cs index 3894e6c5fc..076084c7a3 100644 --- a/Jellyfin.Api/Controllers/DashboardController.cs +++ b/Jellyfin.Api/Controllers/DashboardController.cs @@ -4,7 +4,6 @@ using System.IO; using System.Linq; using System.Net.Mime; using Jellyfin.Api.Attributes; -using Jellyfin.Api.Constants; using Jellyfin.Api.Models; using MediaBrowser.Common.Plugins; using MediaBrowser.Model.Net; @@ -14,103 +13,102 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// The dashboard controller. +/// +[Route("")] +public class DashboardController : BaseJellyfinApiController { + private readonly ILogger _logger; + private readonly IPluginManager _pluginManager; + /// - /// The dashboard controller. + /// Initializes a new instance of the class. /// - [Route("")] - public class DashboardController : BaseJellyfinApiController + /// Instance of interface. + /// Instance of interface. + public DashboardController( + ILogger logger, + IPluginManager pluginManager) { - private readonly ILogger _logger; - private readonly IPluginManager _pluginManager; + _logger = logger; + _pluginManager = pluginManager; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of interface. - /// Instance of interface. - public DashboardController( - ILogger logger, - IPluginManager pluginManager) + /// + /// Gets the configuration pages. + /// + /// Whether to enable in the main menu. + /// ConfigurationPages returned. + /// Server still loading. + /// An with infos about the plugins. + [HttpGet("web/ConfigurationPages")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize] + public ActionResult> GetConfigurationPages( + [FromQuery] bool? enableInMainMenu) + { + var configPages = _pluginManager.Plugins.SelectMany(GetConfigPages).ToList(); + + if (enableInMainMenu.HasValue) { - _logger = logger; - _pluginManager = pluginManager; + configPages = configPages.Where(p => p.EnableInMainMenu == enableInMainMenu.Value).ToList(); } - /// - /// Gets the configuration pages. - /// - /// Whether to enable in the main menu. - /// ConfigurationPages returned. - /// Server still loading. - /// An with infos about the plugins. - [HttpGet("web/ConfigurationPages")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [Authorize(Policy = Policies.DefaultAuthorization)] - public ActionResult> GetConfigurationPages( - [FromQuery] bool? enableInMainMenu) + return configPages; + } + + /// + /// Gets a dashboard configuration page. + /// + /// The name of the page. + /// ConfigurationPage returned. + /// Plugin configuration page not found. + /// The configuration page. + [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) { - var configPages = _pluginManager.Plugins.SelectMany(GetConfigPages).ToList(); - - if (enableInMainMenu.HasValue) - { - configPages = configPages.Where(p => p.EnableInMainMenu == enableInMainMenu.Value).ToList(); - } - - return configPages; + return NotFound(); } - /// - /// Gets a dashboard configuration page. - /// - /// The name of the page. - /// ConfigurationPage returned. - /// Plugin configuration page not found. - /// The configuration page. - [HttpGet("web/ConfigurationPage")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesFile(MediaTypeNames.Text.Html, "application/x-javascript")] - public ActionResult GetDashboardConfigurationPage([FromQuery] string? name) + IPlugin plugin = altPage.Item2; + string resourcePath = altPage.Item1.EmbeddedResourcePath; + Stream? stream = plugin.GetType().Assembly.GetManifestResourceStream(resourcePath); + if (stream is null) { - 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 File(stream, MimeTypes.GetMimeType(resourcePath)); + _logger.LogError("Failed to get resource {Resource} from plugin {Plugin}", resourcePath, plugin.Name); + return NotFound(); } - private IEnumerable GetConfigPages(LocalPlugin plugin) + return File(stream, MimeTypes.GetMimeType(resourcePath)); + } + + private IEnumerable GetConfigPages(LocalPlugin plugin) + { + return GetPluginPages(plugin).Select(i => new ConfigurationPageInfo(plugin.Instance, i.Item1)); + } + + private IEnumerable> GetPluginPages(LocalPlugin plugin) + { + if (plugin.Instance is not IHasWebPages hasWebPages) { - return GetPluginPages(plugin).Select(i => new ConfigurationPageInfo(plugin.Instance, i.Item1)); + return Enumerable.Empty>(); } - private IEnumerable> GetPluginPages(LocalPlugin plugin) - { - if (plugin.Instance is not IHasWebPages hasWebPages) - { - return Enumerable.Empty>(); - } + return hasWebPages.GetPages().Select(i => new Tuple(i, plugin.Instance)); + } - return hasWebPages.GetPages().Select(i => new Tuple(i, plugin.Instance)); - } - - private IEnumerable> GetPluginPages() - { - return _pluginManager.Plugins.SelectMany(GetPluginPages); - } + private IEnumerable> GetPluginPages() + { + return _pluginManager.Plugins.SelectMany(GetPluginPages); } } diff --git a/Jellyfin.Api/Controllers/DevicesController.cs b/Jellyfin.Api/Controllers/DevicesController.cs index aad60cf5cc..aa0dff2123 100644 --- a/Jellyfin.Api/Controllers/DevicesController.cs +++ b/Jellyfin.Api/Controllers/DevicesController.cs @@ -2,6 +2,7 @@ using System; using System.ComponentModel.DataAnnotations; using System.Threading.Tasks; using Jellyfin.Api.Constants; +using Jellyfin.Api.Helpers; using Jellyfin.Data.Dtos; using Jellyfin.Data.Entities.Security; using Jellyfin.Data.Queries; @@ -13,129 +14,129 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// Devices Controller. +/// +[Authorize(Policy = Policies.RequiresElevation)] +public class DevicesController : BaseJellyfinApiController { + private readonly IDeviceManager _deviceManager; + private readonly ISessionManager _sessionManager; + /// - /// Devices Controller. + /// Initializes a new instance of the class. /// - [Authorize(Policy = Policies.RequiresElevation)] - public class DevicesController : BaseJellyfinApiController + /// Instance of interface. + /// Instance of interface. + public DevicesController( + IDeviceManager deviceManager, + ISessionManager sessionManager) { - private readonly IDeviceManager _deviceManager; - private readonly ISessionManager _sessionManager; + _deviceManager = deviceManager; + _sessionManager = sessionManager; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of interface. - /// Instance of interface. - public DevicesController( - IDeviceManager deviceManager, - ISessionManager sessionManager) + /// + /// Get Devices. + /// + /// Gets or sets a value indicating whether [supports synchronize]. + /// Gets or sets the user identifier. + /// Devices retrieved. + /// An containing the list of devices. + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task>> GetDevices([FromQuery] bool? supportsSync, [FromQuery] Guid? userId) + { + userId = RequestHelpers.GetUserId(User, userId); + return await _deviceManager.GetDevicesForUser(userId, supportsSync).ConfigureAwait(false); + } + + /// + /// Get info for a device. + /// + /// Device Id. + /// Device info retrieved. + /// Device not found. + /// An containing the device info on success, or a if the device could not be found. + [HttpGet("Info")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetDeviceInfo([FromQuery, Required] string id) + { + var deviceInfo = await _deviceManager.GetDevice(id).ConfigureAwait(false); + if (deviceInfo is null) { - _deviceManager = deviceManager; - _sessionManager = sessionManager; + return NotFound(); } - /// - /// Get Devices. - /// - /// Gets or sets a value indicating whether [supports synchronize]. - /// Gets or sets the user identifier. - /// Devices retrieved. - /// An containing the list of devices. - [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task>> GetDevices([FromQuery] bool? supportsSync, [FromQuery] Guid? userId) + return deviceInfo; + } + + /// + /// Get options for a device. + /// + /// Device Id. + /// Device options retrieved. + /// Device not found. + /// An containing the device info on success, or a if the device could not be found. + [HttpGet("Options")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetDeviceOptions([FromQuery, Required] string id) + { + var deviceInfo = await _deviceManager.GetDeviceOptions(id).ConfigureAwait(false); + if (deviceInfo is null) { - return await _deviceManager.GetDevicesForUser(userId, supportsSync).ConfigureAwait(false); + return NotFound(); } - /// - /// Get info for a device. - /// - /// Device Id. - /// Device info retrieved. - /// Device not found. - /// An containing the device info on success, or a if the device could not be found. - [HttpGet("Info")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> GetDeviceInfo([FromQuery, Required] string id) - { - var deviceInfo = await _deviceManager.GetDevice(id).ConfigureAwait(false); - if (deviceInfo is null) - { - return NotFound(); - } + return deviceInfo; + } - return deviceInfo; + /// + /// Update device options. + /// + /// Device Id. + /// Device Options. + /// Device options updated. + /// A . + [HttpPost("Options")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task UpdateDeviceOptions( + [FromQuery, Required] string id, + [FromBody, Required] DeviceOptionsDto deviceOptions) + { + await _deviceManager.UpdateDeviceOptions(id, deviceOptions.CustomName).ConfigureAwait(false); + return NoContent(); + } + + /// + /// Deletes a device. + /// + /// Device Id. + /// Device deleted. + /// Device not found. + /// A on success, or a if the device could not be found. + [HttpDelete] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeleteDevice([FromQuery, Required] string id) + { + var existingDevice = await _deviceManager.GetDevice(id).ConfigureAwait(false); + if (existingDevice is null) + { + return NotFound(); } - /// - /// Get options for a device. - /// - /// Device Id. - /// Device options retrieved. - /// Device not found. - /// An containing the device info on success, or a if the device could not be found. - [HttpGet("Options")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> GetDeviceOptions([FromQuery, Required] string id) - { - var deviceInfo = await _deviceManager.GetDeviceOptions(id).ConfigureAwait(false); - if (deviceInfo is null) - { - return NotFound(); - } + var sessions = await _deviceManager.GetDevices(new DeviceQuery { DeviceId = id }).ConfigureAwait(false); - return deviceInfo; + foreach (var session in sessions.Items) + { + await _sessionManager.Logout(session).ConfigureAwait(false); } - /// - /// Update device options. - /// - /// Device Id. - /// Device Options. - /// Device options updated. - /// A . - [HttpPost("Options")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task UpdateDeviceOptions( - [FromQuery, Required] string id, - [FromBody, Required] DeviceOptionsDto deviceOptions) - { - await _deviceManager.UpdateDeviceOptions(id, deviceOptions.CustomName).ConfigureAwait(false); - return NoContent(); - } - - /// - /// Deletes a device. - /// - /// Device Id. - /// Device deleted. - /// Device not found. - /// A on success, or a if the device could not be found. - [HttpDelete] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task 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); - } - - return NoContent(); - } + return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs index 67cceb4a8c..6f0006832b 100644 --- a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs +++ b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs @@ -3,7 +3,6 @@ using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Linq; -using Jellyfin.Api.Constants; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using MediaBrowser.Common.Extensions; @@ -14,201 +13,200 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// Display Preferences Controller. +/// +[Authorize] +public class DisplayPreferencesController : BaseJellyfinApiController { + private readonly IDisplayPreferencesManager _displayPreferencesManager; + private readonly ILogger _logger; + /// - /// Display Preferences Controller. + /// Initializes a new instance of the class. /// - [Authorize(Policy = Policies.DefaultAuthorization)] - public class DisplayPreferencesController : BaseJellyfinApiController + /// Instance of interface. + /// Instance of interface. + public DisplayPreferencesController(IDisplayPreferencesManager displayPreferencesManager, ILogger logger) { - private readonly IDisplayPreferencesManager _displayPreferencesManager; - private readonly ILogger _logger; + _displayPreferencesManager = displayPreferencesManager; + _logger = logger; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of interface. - /// Instance of interface. - public DisplayPreferencesController(IDisplayPreferencesManager displayPreferencesManager, ILogger logger) + /// + /// Get Display Preferences. + /// + /// Display preferences id. + /// User id. + /// Client. + /// Display preferences retrieved. + /// An containing the display preferences on success, or a if the display preferences could not be found. + [HttpGet("{displayPreferencesId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "displayPreferencesId", Justification = "Imported from ServiceStack")] + public ActionResult 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(); } - /// - /// Get Display Preferences. - /// - /// Display preferences id. - /// User id. - /// Client. - /// Display preferences retrieved. - /// An containing the display preferences on success, or a if the display preferences could not be found. - [HttpGet("{displayPreferencesId}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "displayPreferencesId", Justification = "Imported from ServiceStack")] - public ActionResult GetDisplayPreferences( - [FromRoute, Required] string displayPreferencesId, - [FromQuery, Required] Guid userId, - [FromQuery, Required] string client) + var displayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, itemId, client); + var itemPreferences = _displayPreferencesManager.GetItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client); + itemPreferences.ItemId = itemId; + + var dto = new DisplayPreferencesDto { - if (!Guid.TryParse(displayPreferencesId, out var itemId)) - { - itemId = displayPreferencesId.GetMD5(); - } + 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 + }; - 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(); - } - - 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); - } - - // This will essentially be a noop if no changes have been made, but new prefs must be saved at least. - _displayPreferencesManager.SaveChanges(); - - return dto; + foreach (var homeSection in displayPreferences.HomeSections) + { + dto.CustomPrefs["homesection" + homeSection.Order] = homeSection.Type.ToString().ToLowerInvariant(); } - /// - /// Update Display Preferences. - /// - /// Display preferences id. - /// User Id. - /// Client. - /// New Display Preferences object. - /// Display preferences updated. - /// An on success. - [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, - }; + 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; - if (!Guid.TryParse(displayPreferencesId, out var itemId)) + // 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(); + + return dto; + } + + /// + /// Update Display Preferences. + /// + /// Display preferences id. + /// User Id. + /// Client. + /// New Display Preferences object. + /// Display preferences updated. + /// An on success. + [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(); + } + + var existingDisplayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, itemId, client); + existingDisplayPreferences.IndexBy = Enum.TryParse(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, 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(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(displayPreferences.IndexBy, true, out var indexBy) ? indexBy : null; - existingDisplayPreferences.ShowBackdrop = displayPreferences.ShowBackdrop; - existingDisplayPreferences.ShowSidebar = displayPreferences.ShowSidebar; + displayPreferences.CustomPrefs.Remove(key); + existingDisplayPreferences.HomeSections.Add(new HomeSection { Order = order, Type = type }); + } - existingDisplayPreferences.ScrollDirection = displayPreferences.ScrollDirection; - existingDisplayPreferences.ChromecastVersion = displayPreferences.CustomPrefs.TryGetValue("chromecastVersion", out var chromecastVersion) - && !string.IsNullOrEmpty(chromecastVersion) - ? Enum.Parse(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))) + foreach (var key in displayPreferences.CustomPrefs.Keys.Where(key => key.StartsWith("landing-", StringComparison.OrdinalIgnoreCase))) + { + if (!Enum.TryParse(displayPreferences.CustomPrefs[key], true, out var type)) { - var order = int.Parse(key.AsSpan().Slice("homesection".Length), CultureInfo.InvariantCulture); - if (!Enum.TryParse(displayPreferences.CustomPrefs[key], true, out var type)) - { - type = order < 8 ? defaults[order] : HomeSectionType.None; - } - + _logger.LogError("Invalid ViewType: {LandingScreenOption}", displayPreferences.CustomPrefs[key]); 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))) - { - if (!Enum.TryParse(displayPreferences.CustomPrefs[key], true, out var type)) - { - _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; - - // Set all remaining custom preferences. - _displayPreferencesManager.SetCustomItemDisplayPreferences(userId, itemId, existingDisplayPreferences.Client, displayPreferences.CustomPrefs); - _displayPreferencesManager.SaveChanges(); - - return NoContent(); } + + 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(); + + return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/DlnaController.cs b/Jellyfin.Api/Controllers/DlnaController.cs index 07e0590a10..415385463d 100644 --- a/Jellyfin.Api/Controllers/DlnaController.cs +++ b/Jellyfin.Api/Controllers/DlnaController.cs @@ -7,127 +7,126 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// Dlna Controller. +/// +[Authorize(Policy = Policies.RequiresElevation)] +public class DlnaController : BaseJellyfinApiController { + private readonly IDlnaManager _dlnaManager; + /// - /// Dlna Controller. + /// Initializes a new instance of the class. /// - [Authorize(Policy = Policies.RequiresElevation)] - public class DlnaController : BaseJellyfinApiController + /// Instance of the interface. + public DlnaController(IDlnaManager dlnaManager) { - private readonly IDlnaManager _dlnaManager; + _dlnaManager = dlnaManager; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - public DlnaController(IDlnaManager dlnaManager) + /// + /// Get profile infos. + /// + /// Device profile infos returned. + /// An containing the device profile infos. + [HttpGet("ProfileInfos")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> GetProfileInfos() + { + return Ok(_dlnaManager.GetProfileInfos()); + } + + /// + /// Gets the default profile. + /// + /// Default device profile returned. + /// An containing the default profile. + [HttpGet("Profiles/Default")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult GetDefaultProfile() + { + return _dlnaManager.GetDefaultProfile(); + } + + /// + /// Gets a single profile. + /// + /// Profile Id. + /// Device profile returned. + /// Device profile not found. + /// An containing the profile on success, or a if device profile not found. + [HttpGet("Profiles/{profileId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult GetProfile([FromRoute, Required] string profileId) + { + var profile = _dlnaManager.GetProfile(profileId); + if (profile is null) { - _dlnaManager = dlnaManager; + return NotFound(); } - /// - /// Get profile infos. - /// - /// Device profile infos returned. - /// An containing the device profile infos. - [HttpGet("ProfileInfos")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> GetProfileInfos() + return profile; + } + + /// + /// Deletes a profile. + /// + /// Profile id. + /// Device profile deleted. + /// Device profile not found. + /// A on success, or a if profile not found. + [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 Ok(_dlnaManager.GetProfileInfos()); + return NotFound(); } - /// - /// Gets the default profile. - /// - /// Default device profile returned. - /// An containing the default profile. - [HttpGet("Profiles/Default")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult GetDefaultProfile() + _dlnaManager.DeleteProfile(profileId); + return NoContent(); + } + + /// + /// Creates a profile. + /// + /// Device profile. + /// Device profile created. + /// A . + [HttpPost("Profiles")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult CreateProfile([FromBody] DeviceProfile deviceProfile) + { + _dlnaManager.CreateProfile(deviceProfile); + return NoContent(); + } + + /// + /// Updates a profile. + /// + /// Profile id. + /// Device profile. + /// Device profile updated. + /// Device profile not found. + /// A on success, or a if profile not found. + [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 _dlnaManager.GetDefaultProfile(); + return NotFound(); } - /// - /// Gets a single profile. - /// - /// Profile Id. - /// Device profile returned. - /// Device profile not found. - /// An containing the profile on success, or a if device profile not found. - [HttpGet("Profiles/{profileId}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult GetProfile([FromRoute, Required] string profileId) - { - var profile = _dlnaManager.GetProfile(profileId); - if (profile is null) - { - return NotFound(); - } - - return profile; - } - - /// - /// Deletes a profile. - /// - /// Profile id. - /// Device profile deleted. - /// Device profile not found. - /// A on success, or a if profile not found. - [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(); - } - - /// - /// Creates a profile. - /// - /// Device profile. - /// Device profile created. - /// A . - [HttpPost("Profiles")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult CreateProfile([FromBody] DeviceProfile deviceProfile) - { - _dlnaManager.CreateProfile(deviceProfile); - return NoContent(); - } - - /// - /// Updates a profile. - /// - /// Profile id. - /// Device profile. - /// Device profile updated. - /// Device profile not found. - /// A on success, or a if profile not found. - [HttpPost("Profiles/{profileId}")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult UpdateProfile([FromRoute, Required] string profileId, [FromBody] DeviceProfile deviceProfile) - { - var existingDeviceProfile = _dlnaManager.GetProfile(profileId); - if (existingDeviceProfile is null) - { - return NotFound(); - } - - _dlnaManager.UpdateProfile(profileId, deviceProfile); - return NoContent(); - } + _dlnaManager.UpdateProfile(profileId, deviceProfile); + return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/DlnaServerController.cs b/Jellyfin.Api/Controllers/DlnaServerController.cs index 96c492b3ec..95b296fae9 100644 --- a/Jellyfin.Api/Controllers/DlnaServerController.cs +++ b/Jellyfin.Api/Controllers/DlnaServerController.cs @@ -14,311 +14,310 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// Dlna Server Controller. +/// +[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; + /// - /// Dlna Server Controller. + /// Initializes a new instance of the class. /// - [Route("Dlna")] - [DlnaEnabled] - [Authorize(Policy = Policies.AnonymousLanAccessPolicy)] - public class DlnaServerController : BaseJellyfinApiController + /// Instance of the interface. + 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; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - public DlnaServerController(IDlnaManager dlnaManager) + /// + /// Get Description Xml. + /// + /// Server UUID. + /// Description xml returned. + /// DLNA is disabled. + /// An containing the description xml. + [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 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); + } + + /// + /// Gets Dlna content directory xml. + /// + /// Server UUID. + /// Dlna content directory returned. + /// DLNA is disabled. + /// An containing the dlna content directory xml. + [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 GetContentDirectory([FromRoute, Required] string serverId) + { + return Ok(_contentDirectory.GetServiceXml()); + } + + /// + /// Gets Dlna media receiver registrar xml. + /// + /// Server UUID. + /// Dlna media receiver registrar xml returned. + /// DLNA is disabled. + /// Dlna media receiver registrar xml. + [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 GetMediaReceiverRegistrar([FromRoute, Required] string serverId) + { + return Ok(_mediaReceiverRegistrar.GetServiceXml()); + } + + /// + /// Gets Dlna media receiver registrar xml. + /// + /// Server UUID. + /// Dlna media receiver registrar xml returned. + /// DLNA is disabled. + /// Dlna media receiver registrar xml. + [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 GetConnectionManager([FromRoute, Required] string serverId) + { + return Ok(_connectionManager.GetServiceXml()); + } + + /// + /// Process a content directory control request. + /// + /// Server UUID. + /// Request processed. + /// DLNA is disabled. + /// Control response. + [HttpPost("{serverId}/ContentDirectory/Control")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] + public async Task> ProcessContentDirectoryControlRequest([FromRoute, Required] string serverId) + { + return await ProcessControlRequestInternalAsync(serverId, Request.Body, _contentDirectory).ConfigureAwait(false); + } + + /// + /// Process a connection manager control request. + /// + /// Server UUID. + /// Request processed. + /// DLNA is disabled. + /// Control response. + [HttpPost("{serverId}/ConnectionManager/Control")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] + public async Task> ProcessConnectionManagerControlRequest([FromRoute, Required] string serverId) + { + return await ProcessControlRequestInternalAsync(serverId, Request.Body, _connectionManager).ConfigureAwait(false); + } + + /// + /// Process a media receiver registrar control request. + /// + /// Server UUID. + /// Request processed. + /// DLNA is disabled. + /// Control response. + [HttpPost("{serverId}/MediaReceiverRegistrar/Control")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] + public async Task> ProcessMediaReceiverRegistrarControlRequest([FromRoute, Required] string serverId) + { + return await ProcessControlRequestInternalAsync(serverId, Request.Body, _mediaReceiverRegistrar).ConfigureAwait(false); + } + + /// + /// Processes an event subscription request. + /// + /// Server UUID. + /// Request processed. + /// DLNA is disabled. + /// Event subscription response. + [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 ProcessMediaReceiverRegistrarEventRequest(string serverId) + { + return ProcessEventRequest(_mediaReceiverRegistrar); + } + + /// + /// Processes an event subscription request. + /// + /// Server UUID. + /// Request processed. + /// DLNA is disabled. + /// Event subscription response. + [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 ProcessContentDirectoryEventRequest(string serverId) + { + return ProcessEventRequest(_contentDirectory); + } + + /// + /// Processes an event subscription request. + /// + /// Server UUID. + /// Request processed. + /// DLNA is disabled. + /// Event subscription response. + [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 ProcessConnectionManagerEventRequest(string serverId) + { + return ProcessEventRequest(_connectionManager); + } + + /// + /// Gets a server icon. + /// + /// Server UUID. + /// The icon filename. + /// Request processed. + /// Not Found. + /// DLNA is disabled. + /// Icon stream. + [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); + } + + /// + /// Gets a server icon. + /// + /// The icon filename. + /// Icon stream. + /// Request processed. + /// Not Found. + /// DLNA is disabled. + [HttpGet("icons/{fileName}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + [ProducesImageFile] + public ActionResult GetIcon([FromRoute, Required] string fileName) + { + return GetIconInternal(fileName); + } + + private ActionResult GetIconInternal(string fileName) + { + var icon = _dlnaManager.GetIcon(fileName); + if (icon is null) { - _dlnaManager = dlnaManager; - _contentDirectory = DlnaEntryPoint.Current.ContentDirectory; - _connectionManager = DlnaEntryPoint.Current.ConnectionManager; - _mediaReceiverRegistrar = DlnaEntryPoint.Current.MediaReceiverRegistrar; + return NotFound(); } - /// - /// Get Description Xml. - /// - /// Server UUID. - /// Description xml returned. - /// DLNA is disabled. - /// An containing the description xml. - [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 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); - } + return File(icon.Stream, MimeTypes.GetMimeType(fileName)); + } - /// - /// Gets Dlna content directory xml. - /// - /// Server UUID. - /// Dlna content directory returned. - /// DLNA is disabled. - /// An containing the dlna content directory xml. - [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 GetContentDirectory([FromRoute, Required] string serverId) - { - return Ok(_contentDirectory.GetServiceXml()); - } + private string GetAbsoluteUri() + { + return $"{Request.Scheme}://{Request.Host}{Request.PathBase}{Request.Path}"; + } - /// - /// Gets Dlna media receiver registrar xml. - /// - /// Server UUID. - /// Dlna media receiver registrar xml returned. - /// DLNA is disabled. - /// Dlna media receiver registrar xml. - [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 GetMediaReceiverRegistrar([FromRoute, Required] string serverId) + private Task ProcessControlRequestInternalAsync(string id, Stream requestStream, IUpnpService service) + { + return service.ProcessControlRequestAsync(new ControlRequest(Request.Headers) { - return Ok(_mediaReceiverRegistrar.GetServiceXml()); - } + InputXml = requestStream, + TargetServerUuId = id, + RequestedUrl = GetAbsoluteUri() + }); + } - /// - /// Gets Dlna media receiver registrar xml. - /// - /// Server UUID. - /// Dlna media receiver registrar xml returned. - /// DLNA is disabled. - /// Dlna media receiver registrar xml. - [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 GetConnectionManager([FromRoute, Required] string serverId) + private EventSubscriptionResponse ProcessEventRequest(IDlnaEventManager dlnaEventManager) + { + var subscriptionId = Request.Headers["SID"]; + if (string.Equals(Request.Method, "subscribe", StringComparison.OrdinalIgnoreCase)) { - return Ok(_connectionManager.GetServiceXml()); - } + var notificationType = Request.Headers["NT"]; + var callback = Request.Headers["CALLBACK"]; + var timeoutString = Request.Headers["TIMEOUT"]; - /// - /// Process a content directory control request. - /// - /// Server UUID. - /// Request processed. - /// DLNA is disabled. - /// Control response. - [HttpPost("{serverId}/ContentDirectory/Control")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] - [Produces(MediaTypeNames.Text.Xml)] - [ProducesFile(MediaTypeNames.Text.Xml)] - public async Task> ProcessContentDirectoryControlRequest([FromRoute, Required] string serverId) - { - return await ProcessControlRequestInternalAsync(serverId, Request.Body, _contentDirectory).ConfigureAwait(false); - } - - /// - /// Process a connection manager control request. - /// - /// Server UUID. - /// Request processed. - /// DLNA is disabled. - /// Control response. - [HttpPost("{serverId}/ConnectionManager/Control")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] - [Produces(MediaTypeNames.Text.Xml)] - [ProducesFile(MediaTypeNames.Text.Xml)] - public async Task> ProcessConnectionManagerControlRequest([FromRoute, Required] string serverId) - { - return await ProcessControlRequestInternalAsync(serverId, Request.Body, _connectionManager).ConfigureAwait(false); - } - - /// - /// Process a media receiver registrar control request. - /// - /// Server UUID. - /// Request processed. - /// DLNA is disabled. - /// Control response. - [HttpPost("{serverId}/MediaReceiverRegistrar/Control")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] - [Produces(MediaTypeNames.Text.Xml)] - [ProducesFile(MediaTypeNames.Text.Xml)] - public async Task> ProcessMediaReceiverRegistrarControlRequest([FromRoute, Required] string serverId) - { - return await ProcessControlRequestInternalAsync(serverId, Request.Body, _mediaReceiverRegistrar).ConfigureAwait(false); - } - - /// - /// Processes an event subscription request. - /// - /// Server UUID. - /// Request processed. - /// DLNA is disabled. - /// Event subscription response. - [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 ProcessMediaReceiverRegistrarEventRequest(string serverId) - { - return ProcessEventRequest(_mediaReceiverRegistrar); - } - - /// - /// Processes an event subscription request. - /// - /// Server UUID. - /// Request processed. - /// DLNA is disabled. - /// Event subscription response. - [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 ProcessContentDirectoryEventRequest(string serverId) - { - return ProcessEventRequest(_contentDirectory); - } - - /// - /// Processes an event subscription request. - /// - /// Server UUID. - /// Request processed. - /// DLNA is disabled. - /// Event subscription response. - [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 ProcessConnectionManagerEventRequest(string serverId) - { - return ProcessEventRequest(_connectionManager); - } - - /// - /// Gets a server icon. - /// - /// Server UUID. - /// The icon filename. - /// Request processed. - /// Not Found. - /// DLNA is disabled. - /// Icon stream. - [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); - } - - /// - /// Gets a server icon. - /// - /// The icon filename. - /// Icon stream. - /// Request processed. - /// Not Found. - /// DLNA is disabled. - [HttpGet("icons/{fileName}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] - [ProducesImageFile] - public ActionResult GetIcon([FromRoute, Required] string fileName) - { - return GetIconInternal(fileName); - } - - private ActionResult GetIconInternal(string fileName) - { - var icon = _dlnaManager.GetIcon(fileName); - if (icon is null) + if (string.IsNullOrEmpty(notificationType)) { - return NotFound(); + return dlnaEventManager.RenewEventSubscription( + subscriptionId, + notificationType, + timeoutString, + callback); } - return File(icon.Stream, MimeTypes.GetMimeType(fileName)); + return dlnaEventManager.CreateEventSubscription(notificationType, timeoutString, callback); } - private string GetAbsoluteUri() - { - return $"{Request.Scheme}://{Request.Host}{Request.PathBase}{Request.Path}"; - } - - private Task ProcessControlRequestInternalAsync(string id, Stream requestStream, IUpnpService service) - { - return service.ProcessControlRequestAsync(new ControlRequest(Request.Headers) - { - InputXml = requestStream, - TargetServerUuId = id, - RequestedUrl = GetAbsoluteUri() - }); - } - - private EventSubscriptionResponse ProcessEventRequest(IDlnaEventManager dlnaEventManager) - { - var subscriptionId = Request.Headers["SID"]; - if (string.Equals(Request.Method, "subscribe", StringComparison.OrdinalIgnoreCase)) - { - 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.CancelEventSubscription(subscriptionId); - } + return dlnaEventManager.CancelEventSubscription(subscriptionId); } } diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs index b41e239255..ce684e457c 100644 --- a/Jellyfin.Api/Controllers/DynamicHlsController.cs +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -9,10 +9,11 @@ using System.Text; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Attributes; -using Jellyfin.Api.Constants; using Jellyfin.Api.Helpers; using Jellyfin.Api.Models.PlaybackDtos; using Jellyfin.Api.Models.StreamingDtos; +using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using Jellyfin.MediaEncoding.Hls.Playlist; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Configuration; @@ -20,6 +21,7 @@ using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Dlna; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.MediaEncoding.Encoder; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Entities; @@ -30,2026 +32,2075 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// Dynamic hls controller. +/// +[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 _logger; + private readonly EncodingHelper _encodingHelper; + private readonly IDynamicHlsPlaylistGenerator _dynamicHlsPlaylistGenerator; + private readonly DynamicHlsHelper _dynamicHlsHelper; + private readonly EncodingOptions _encodingOptions; + /// - /// Dynamic hls controller. + /// Initializes a new instance of the class. /// - [Route("")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class DynamicHlsController : BaseJellyfinApiController + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the class. + /// Instance of the interface. + /// Instance of . + /// Instance of . + /// Instance of . + public DynamicHlsController( + ILibraryManager libraryManager, + IUserManager userManager, + IDlnaManager dlnaManager, + IMediaSourceManager mediaSourceManager, + IServerConfigurationManager serverConfigurationManager, + IMediaEncoder mediaEncoder, + IFileSystem fileSystem, + IDeviceManager deviceManager, + TranscodingJobHelper transcodingJobHelper, + ILogger 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; + _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; - 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 _logger; - private readonly EncodingHelper _encodingHelper; - private readonly IDynamicHlsPlaylistGenerator _dynamicHlsPlaylistGenerator; - private readonly DynamicHlsHelper _dynamicHlsHelper; - private readonly EncodingOptions _encodingOptions; + _encodingOptions = serverConfigurationManager.GetEncodingOptions(); + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the class. - /// Instance of the interface. - /// Instance of . - /// Instance of . - /// Instance of . - public DynamicHlsController( - ILibraryManager libraryManager, - IUserManager userManager, - IDlnaManager dlnaManager, - IMediaSourceManager mediaSourceManager, - IServerConfigurationManager serverConfigurationManager, - IMediaEncoder mediaEncoder, - IFileSystem fileSystem, - IDeviceManager deviceManager, - TranscodingJobHelper transcodingJobHelper, - ILogger logger, - DynamicHlsHelper dynamicHlsHelper, - EncodingHelper encodingHelper, - IDynamicHlsPlaylistGenerator dynamicHlsPlaylistGenerator) + /// + /// Gets a hls live stream. + /// + /// The item id. + /// The audio container. + /// 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. + /// The streaming parameters. + /// The tag. + /// Optional. The dlna device profile id to utilize. + /// The play session id. + /// The segment container. + /// The segment length. + /// The minimum number of segments. + /// The media version id, if playing an alternate version. + /// The device id of the client requesting. Used to stop encoding processes when needed. + /// 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. + /// Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true. + /// Whether or not to allow copying of the video stream url. + /// Whether or not to allow copying of the audio stream url. + /// Optional. Whether to break on non key frames. + /// Optional. Specify a specific audio sample rate, e.g. 44100. + /// Optional. The maximum audio bit depth. + /// Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults. + /// Optional. Specify a specific number of audio channels to encode to, e.g. 2. + /// Optional. Specify a maximum number of audio channels to encode to, e.g. 2. + /// Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high. + /// Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1. + /// Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements. + /// Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements. + /// Whether or not to copy timestamps when transcoding with an offset. Defaults to false. + /// Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms. + /// Optional. The fixed horizontal resolution of the encoded video. + /// Optional. The fixed vertical resolution of the encoded video. + /// Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults. + /// Optional. The index of the subtitle stream to use. If omitted no subtitles will be used. + /// Optional. Specify the subtitle delivery method. + /// Optional. + /// Optional. The maximum video bit depth. + /// Optional. Whether to require avc. + /// Optional. Whether to deinterlace the video. + /// Optional. Whether to require a non anamorphic stream. + /// Optional. The maximum number of audio channels to transcode. + /// Optional. The limit of how many cpu cores to use. + /// The live stream id. + /// Optional. Whether to enable the MpegtsM2Ts mode. + /// 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. + /// Optional. Specify a subtitle codec to encode to. + /// Optional. The transcoding reason. + /// Optional. The index of the audio stream to use. If omitted the first audio stream will be used. + /// Optional. The index of the video stream to use. If omitted the first video stream will be used. + /// Optional. The . + /// Optional. The streaming options. + /// Optional. The max width. + /// Optional. The max height. + /// Optional. Whether to enable subtitles in the manifest. + /// Hls live stream retrieved. + /// A containing the hls file. + [HttpGet("Videos/{itemId}/live.m3u8")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesPlaylistFile] + public async Task 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 streamOptions, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] bool? enableSubtitlesInManifest) + { + VideoRequestDto streamingRequest = new VideoRequestDto { - _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; + 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 + }; - _encodingOptions = serverConfigurationManager.GetEncodingOptions(); - } + // 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); - /// - /// Gets a hls live stream. - /// - /// The item id. - /// The audio container. - /// 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. - /// The streaming parameters. - /// The tag. - /// Optional. The dlna device profile id to utilize. - /// The play session id. - /// The segment container. - /// The segment length. - /// The minimum number of segments. - /// The media version id, if playing an alternate version. - /// The device id of the client requesting. Used to stop encoding processes when needed. - /// 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. - /// Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true. - /// Whether or not to allow copying of the video stream url. - /// Whether or not to allow copying of the audio stream url. - /// Optional. Whether to break on non key frames. - /// Optional. Specify a specific audio sample rate, e.g. 44100. - /// Optional. The maximum audio bit depth. - /// Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults. - /// Optional. Specify a specific number of audio channels to encode to, e.g. 2. - /// Optional. Specify a maximum number of audio channels to encode to, e.g. 2. - /// Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high. - /// Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1. - /// Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements. - /// Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements. - /// Whether or not to copy timestamps when transcoding with an offset. Defaults to false. - /// Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms. - /// Optional. The fixed horizontal resolution of the encoded video. - /// Optional. The fixed vertical resolution of the encoded video. - /// Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults. - /// Optional. The index of the subtitle stream to use. If omitted no subtitles will be used. - /// Optional. Specify the subtitle delivery method. - /// Optional. - /// Optional. The maximum video bit depth. - /// Optional. Whether to require avc. - /// Optional. Whether to deinterlace the video. - /// Optional. Whether to require a non anamorphic stream. - /// Optional. The maximum number of audio channels to transcode. - /// Optional. The limit of how many cpu cores to use. - /// The live stream id. - /// Optional. Whether to enable the MpegtsM2Ts mode. - /// 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. - /// Optional. Specify a subtitle codec to encode to. - /// Optional. The transcoding reason. - /// Optional. The index of the audio stream to use. If omitted the first audio stream will be used. - /// Optional. The index of the video stream to use. If omitted the first video stream will be used. - /// Optional. The . - /// Optional. The streaming options. - /// Optional. The max width. - /// Optional. The max height. - /// Optional. Whether to enable subtitles in the manifest. - /// Hls live stream retrieved. - /// A containing the hls file. - [HttpGet("Videos/{itemId}/live.m3u8")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesPlaylistFile] - public async Task 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 streamOptions, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] bool? enableSubtitlesInManifest) + TranscodingJobDto? job = null; + var playlistPath = Path.ChangeExtension(state.OutputFilePath, ".m3u8"); + + if (!System.IO.File.Exists(playlistPath)) { - 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)) - { - var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlistPath); - await transcodingLock.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - if (!System.IO.File.Exists(playlistPath)) - { - // 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; - } - - 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) - { - _transcodingJobHelper.OnTranscodeEndRequest(job); - } - - var playlistText = HlsHelpers.GetLivePlaylistText(playlistPath, state); - - return Content(playlistText, MimeTypes.GetMimeType("playlist.m3u8")); - } - - /// - /// Gets a video hls playlist stream. - /// - /// The item id. - /// 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. - /// The streaming parameters. - /// The tag. - /// Optional. The dlna device profile id to utilize. - /// The play session id. - /// The segment container. - /// The segment length. - /// The minimum number of segments. - /// The media version id, if playing an alternate version. - /// The device id of the client requesting. Used to stop encoding processes when needed. - /// 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. - /// Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true. - /// Whether or not to allow copying of the video stream url. - /// Whether or not to allow copying of the audio stream url. - /// Optional. Whether to break on non key frames. - /// Optional. Specify a specific audio sample rate, e.g. 44100. - /// Optional. The maximum audio bit depth. - /// Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults. - /// Optional. Specify a specific number of audio channels to encode to, e.g. 2. - /// Optional. Specify a maximum number of audio channels to encode to, e.g. 2. - /// Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high. - /// Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1. - /// Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements. - /// Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements. - /// Whether or not to copy timestamps when transcoding with an offset. Defaults to false. - /// Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms. - /// Optional. The fixed horizontal resolution of the encoded video. - /// Optional. The fixed vertical resolution of the encoded video. - /// Optional. The maximum horizontal resolution of the encoded video. - /// Optional. The maximum vertical resolution of the encoded video. - /// Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults. - /// Optional. The index of the subtitle stream to use. If omitted no subtitles will be used. - /// Optional. Specify the subtitle delivery method. - /// Optional. - /// Optional. The maximum video bit depth. - /// Optional. Whether to require avc. - /// Optional. Whether to deinterlace the video. - /// Optional. Whether to require a non anamorphic stream. - /// Optional. The maximum number of audio channels to transcode. - /// Optional. The limit of how many cpu cores to use. - /// The live stream id. - /// Optional. Whether to enable the MpegtsM2Ts mode. - /// 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. - /// Optional. Specify a subtitle codec to encode to. - /// Optional. The transcoding reason. - /// Optional. The index of the audio stream to use. If omitted the first audio stream will be used. - /// Optional. The index of the video stream to use. If omitted the first video stream will be used. - /// Optional. The . - /// Optional. The streaming options. - /// Enable adaptive bitrate streaming. - /// Video stream returned. - /// A containing the playlist file. - [HttpGet("Videos/{itemId}/master.m3u8")] - [HttpHead("Videos/{itemId}/master.m3u8", Name = "HeadMasterHlsVideoPlaylist")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesPlaylistFile] - public async Task 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 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 - }; - - return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false); - } - - /// - /// Gets an audio hls playlist stream. - /// - /// The item id. - /// 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. - /// The streaming parameters. - /// The tag. - /// Optional. The dlna device profile id to utilize. - /// The play session id. - /// The segment container. - /// The segment length. - /// The minimum number of segments. - /// The media version id, if playing an alternate version. - /// The device id of the client requesting. Used to stop encoding processes when needed. - /// 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. - /// Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true. - /// Whether or not to allow copying of the video stream url. - /// Whether or not to allow copying of the audio stream url. - /// Optional. Whether to break on non key frames. - /// Optional. Specify a specific audio sample rate, e.g. 44100. - /// Optional. The maximum audio bit depth. - /// Optional. The maximum streaming bitrate. - /// Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults. - /// Optional. Specify a specific number of audio channels to encode to, e.g. 2. - /// Optional. Specify a maximum number of audio channels to encode to, e.g. 2. - /// Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high. - /// Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1. - /// Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements. - /// Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements. - /// Whether or not to copy timestamps when transcoding with an offset. Defaults to false. - /// Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms. - /// Optional. The fixed horizontal resolution of the encoded video. - /// Optional. The fixed vertical resolution of the encoded video. - /// Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults. - /// Optional. The index of the subtitle stream to use. If omitted no subtitles will be used. - /// Optional. Specify the subtitle delivery method. - /// Optional. - /// Optional. The maximum video bit depth. - /// Optional. Whether to require avc. - /// Optional. Whether to deinterlace the video. - /// Optional. Whether to require a non anamorphic stream. - /// Optional. The maximum number of audio channels to transcode. - /// Optional. The limit of how many cpu cores to use. - /// The live stream id. - /// Optional. Whether to enable the MpegtsM2Ts mode. - /// 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. - /// Optional. Specify a subtitle codec to encode to. - /// Optional. The transcoding reason. - /// Optional. The index of the audio stream to use. If omitted the first audio stream will be used. - /// Optional. The index of the video stream to use. If omitted the first video stream will be used. - /// Optional. The . - /// Optional. The streaming options. - /// Enable adaptive bitrate streaming. - /// Audio stream returned. - /// A containing the playlist file. - [HttpGet("Audio/{itemId}/master.m3u8")] - [HttpHead("Audio/{itemId}/master.m3u8", Name = "HeadMasterHlsAudioPlaylist")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesPlaylistFile] - public async Task 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 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 - }; - - return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false); - } - - /// - /// Gets a video stream using HTTP live streaming. - /// - /// The item id. - /// 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. - /// The streaming parameters. - /// The tag. - /// Optional. The dlna device profile id to utilize. - /// The play session id. - /// The segment container. - /// The segment length. - /// The minimum number of segments. - /// The media version id, if playing an alternate version. - /// The device id of the client requesting. Used to stop encoding processes when needed. - /// 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. - /// Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true. - /// Whether or not to allow copying of the video stream url. - /// Whether or not to allow copying of the audio stream url. - /// Optional. Whether to break on non key frames. - /// Optional. Specify a specific audio sample rate, e.g. 44100. - /// Optional. The maximum audio bit depth. - /// Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults. - /// Optional. Specify a specific number of audio channels to encode to, e.g. 2. - /// Optional. Specify a maximum number of audio channels to encode to, e.g. 2. - /// Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high. - /// Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1. - /// Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements. - /// Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements. - /// Whether or not to copy timestamps when transcoding with an offset. Defaults to false. - /// Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms. - /// Optional. The fixed horizontal resolution of the encoded video. - /// Optional. The fixed vertical resolution of the encoded video. - /// Optional. The maximum horizontal resolution of the encoded video. - /// Optional. The maximum vertical resolution of the encoded video. - /// Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults. - /// Optional. The index of the subtitle stream to use. If omitted no subtitles will be used. - /// Optional. Specify the subtitle delivery method. - /// Optional. - /// Optional. The maximum video bit depth. - /// Optional. Whether to require avc. - /// Optional. Whether to deinterlace the video. - /// Optional. Whether to require a non anamorphic stream. - /// Optional. The maximum number of audio channels to transcode. - /// Optional. The limit of how many cpu cores to use. - /// The live stream id. - /// Optional. Whether to enable the MpegtsM2Ts mode. - /// 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. - /// Optional. Specify a subtitle codec to encode to. - /// Optional. The transcoding reason. - /// Optional. The index of the audio stream to use. If omitted the first audio stream will be used. - /// Optional. The index of the video stream to use. If omitted the first video stream will be used. - /// Optional. The . - /// Optional. The streaming options. - /// Video stream returned. - /// A containing the audio file. - [HttpGet("Videos/{itemId}/main.m3u8")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesPlaylistFile] - public async Task 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 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); - } - - /// - /// Gets an audio stream using HTTP live streaming. - /// - /// The item id. - /// 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. - /// The streaming parameters. - /// The tag. - /// Optional. The dlna device profile id to utilize. - /// The play session id. - /// The segment container. - /// The segment length. - /// The minimum number of segments. - /// The media version id, if playing an alternate version. - /// The device id of the client requesting. Used to stop encoding processes when needed. - /// 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. - /// Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true. - /// Whether or not to allow copying of the video stream url. - /// Whether or not to allow copying of the audio stream url. - /// Optional. Whether to break on non key frames. - /// Optional. Specify a specific audio sample rate, e.g. 44100. - /// Optional. The maximum audio bit depth. - /// Optional. The maximum streaming bitrate. - /// Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults. - /// Optional. Specify a specific number of audio channels to encode to, e.g. 2. - /// Optional. Specify a maximum number of audio channels to encode to, e.g. 2. - /// Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high. - /// Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1. - /// Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements. - /// Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements. - /// Whether or not to copy timestamps when transcoding with an offset. Defaults to false. - /// Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms. - /// Optional. The fixed horizontal resolution of the encoded video. - /// Optional. The fixed vertical resolution of the encoded video. - /// Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults. - /// Optional. The index of the subtitle stream to use. If omitted no subtitles will be used. - /// Optional. Specify the subtitle delivery method. - /// Optional. - /// Optional. The maximum video bit depth. - /// Optional. Whether to require avc. - /// Optional. Whether to deinterlace the video. - /// Optional. Whether to require a non anamorphic stream. - /// Optional. The maximum number of audio channels to transcode. - /// Optional. The limit of how many cpu cores to use. - /// The live stream id. - /// Optional. Whether to enable the MpegtsM2Ts mode. - /// 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. - /// Optional. Specify a subtitle codec to encode to. - /// Optional. The transcoding reason. - /// Optional. The index of the audio stream to use. If omitted the first audio stream will be used. - /// Optional. The index of the video stream to use. If omitted the first video stream will be used. - /// Optional. The . - /// Optional. The streaming options. - /// Audio stream returned. - /// A containing the audio file. - [HttpGet("Audio/{itemId}/main.m3u8")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesPlaylistFile] - public async Task 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 streamOptions) - { - 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 - }; - - return await GetVariantPlaylistInternal(streamingRequest, cancellationTokenSource) - .ConfigureAwait(false); - } - - /// - /// Gets a video stream using HTTP live streaming. - /// - /// The item id. - /// The playlist id. - /// The segment id. - /// The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. - /// The position of the requested segment in ticks. - /// The length of the requested segment in ticks. - /// 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. - /// The streaming parameters. - /// The tag. - /// Optional. The dlna device profile id to utilize. - /// The play session id. - /// The segment container. - /// The desired segment length. - /// The minimum number of segments. - /// The media version id, if playing an alternate version. - /// The device id of the client requesting. Used to stop encoding processes when needed. - /// 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. - /// Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true. - /// Whether or not to allow copying of the video stream url. - /// Whether or not to allow copying of the audio stream url. - /// Optional. Whether to break on non key frames. - /// Optional. Specify a specific audio sample rate, e.g. 44100. - /// Optional. The maximum audio bit depth. - /// Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults. - /// Optional. Specify a specific number of audio channels to encode to, e.g. 2. - /// Optional. Specify a maximum number of audio channels to encode to, e.g. 2. - /// Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high. - /// Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1. - /// Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements. - /// Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements. - /// Whether or not to copy timestamps when transcoding with an offset. Defaults to false. - /// Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms. - /// Optional. The fixed horizontal resolution of the encoded video. - /// Optional. The fixed vertical resolution of the encoded video. - /// Optional. The maximum horizontal resolution of the encoded video. - /// Optional. The maximum vertical resolution of the encoded video. - /// Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults. - /// Optional. The index of the subtitle stream to use. If omitted no subtitles will be used. - /// Optional. Specify the subtitle delivery method. - /// Optional. - /// Optional. The maximum video bit depth. - /// Optional. Whether to require avc. - /// Optional. Whether to deinterlace the video. - /// Optional. Whether to require a non anamorphic stream. - /// Optional. The maximum number of audio channels to transcode. - /// Optional. The limit of how many cpu cores to use. - /// The live stream id. - /// Optional. Whether to enable the MpegtsM2Ts mode. - /// 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. - /// Optional. Specify a subtitle codec to encode to. - /// Optional. The transcoding reason. - /// Optional. The index of the audio stream to use. If omitted the first audio stream will be used. - /// Optional. The index of the video stream to use. If omitted the first video stream will be used. - /// Optional. The . - /// Optional. The streaming options. - /// Video stream returned. - /// A containing the audio file. - [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 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 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); - } - - /// - /// Gets a video stream using HTTP live streaming. - /// - /// The item id. - /// The playlist id. - /// The segment id. - /// The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. - /// The position of the requested segment in ticks. - /// The length of the requested segment in ticks. - /// 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. - /// The streaming parameters. - /// The tag. - /// Optional. The dlna device profile id to utilize. - /// The play session id. - /// The segment container. - /// The segment length. - /// The minimum number of segments. - /// The media version id, if playing an alternate version. - /// The device id of the client requesting. Used to stop encoding processes when needed. - /// 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. - /// Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true. - /// Whether or not to allow copying of the video stream url. - /// Whether or not to allow copying of the audio stream url. - /// Optional. Whether to break on non key frames. - /// Optional. Specify a specific audio sample rate, e.g. 44100. - /// Optional. The maximum audio bit depth. - /// Optional. The maximum streaming bitrate. - /// Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults. - /// Optional. Specify a specific number of audio channels to encode to, e.g. 2. - /// Optional. Specify a maximum number of audio channels to encode to, e.g. 2. - /// Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high. - /// Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1. - /// Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements. - /// Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements. - /// Whether or not to copy timestamps when transcoding with an offset. Defaults to false. - /// Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms. - /// Optional. The fixed horizontal resolution of the encoded video. - /// Optional. The fixed vertical resolution of the encoded video. - /// Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults. - /// Optional. The index of the subtitle stream to use. If omitted no subtitles will be used. - /// Optional. Specify the subtitle delivery method. - /// Optional. - /// Optional. The maximum video bit depth. - /// Optional. Whether to require avc. - /// Optional. Whether to deinterlace the video. - /// Optional. Whether to require a non anamorphic stream. - /// Optional. The maximum number of audio channels to transcode. - /// Optional. The limit of how many cpu cores to use. - /// The live stream id. - /// Optional. Whether to enable the MpegtsM2Ts mode. - /// 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. - /// Optional. Specify a subtitle codec to encode to. - /// Optional. The transcoding reason. - /// Optional. The index of the audio stream to use. If omitted the first audio stream will be used. - /// Optional. The index of the video stream to use. If omitted the first video stream will be used. - /// Optional. The . - /// Optional. The streaming options. - /// Video stream returned. - /// A containing the audio file. - [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 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 streamOptions) - { - 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 - }; - - return await GetDynamicSegment(streamingRequest, segmentId) - .ConfigureAwait(false); - } - - private async Task 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 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); - - var playlistPath = Path.ChangeExtension(state.OutputFilePath, ".m3u8"); - - var segmentPath = GetSegmentPath(state, playlistPath, segmentId); - - var segmentExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer); - - TranscodingJobDto? job; - - 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); - } - var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlistPath); await transcodingLock.WaitAsync(cancellationToken).ConfigureAwait(false); - var released = false; - var startTranscoding = false; - try { - if (System.IO.File.Exists(segmentPath)) + if (!System.IO.File.Exists(playlistPath)) { - 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); - } - else - { - var currentTranscodingIndex = GetCurrentTranscodingIndex(playlistPath, segmentExtension); - var segmentGapRequiringTranscodingChange = 24 / state.SegmentLength; - - if (segmentId == -1) + // If the playlist doesn't already exist, startup ffmpeg + try { - _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; - } - - if (startTranscoding) - { - // 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; - - state.WaitForPath = segmentPath; - job = await _transcodingJobHelper.StartFfMpeg( + job = await _transcodingJobHelper.StartFfMpeg( state, playlistPath, - GetCommandLineArguments(playlistPath, state, false, segmentId), + GetCommandLineArguments(playlistPath, state, true, 0), Request, TranscodingJobType, - cancellationTokenSource).ConfigureAwait(false); - } - catch - { - state.Dispose(); - throw; - } - - // await WaitForMinimumSegmentCount(playlistPath, 1, cancellationTokenSource.Token).ConfigureAwait(false); + cancellationTokenSource) + .ConfigureAwait(false); + job.IsLiveOutput = true; } - else + catch { - job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); - if (job?.TranscodingThrottler is not null) - { - await job.TranscodingThrottler.UnpauseTranscoding().ConfigureAwait(false); - } + state.Dispose(); + throw; + } + + minSegments = state.MinSegments; + if (minSegments > 0) + { + await HlsHelpers.WaitForMinimumSegmentCount(playlistPath, minSegments, _logger, cancellationToken).ConfigureAwait(false); } } } finally { - if (!released) - { - transcodingLock.Release(); - } + transcodingLock.Release(); } + } - _logger.LogDebug("returning {0} [general case]", segmentPath); - job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); + job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); + + if (job is not null) + { + _transcodingJobHelper.OnTranscodeEndRequest(job); + } + + var playlistText = HlsHelpers.GetLivePlaylistText(playlistPath, state); + + return Content(playlistText, MimeTypes.GetMimeType("playlist.m3u8")); + } + + /// + /// Gets a video hls playlist stream. + /// + /// The item id. + /// 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. + /// The streaming parameters. + /// The tag. + /// Optional. The dlna device profile id to utilize. + /// The play session id. + /// The segment container. + /// The segment length. + /// The minimum number of segments. + /// The media version id, if playing an alternate version. + /// The device id of the client requesting. Used to stop encoding processes when needed. + /// 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. + /// Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true. + /// Whether or not to allow copying of the video stream url. + /// Whether or not to allow copying of the audio stream url. + /// Optional. Whether to break on non key frames. + /// Optional. Specify a specific audio sample rate, e.g. 44100. + /// Optional. The maximum audio bit depth. + /// Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults. + /// Optional. Specify a specific number of audio channels to encode to, e.g. 2. + /// Optional. Specify a maximum number of audio channels to encode to, e.g. 2. + /// Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high. + /// Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1. + /// Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements. + /// Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements. + /// Whether or not to copy timestamps when transcoding with an offset. Defaults to false. + /// Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms. + /// Optional. The fixed horizontal resolution of the encoded video. + /// Optional. The fixed vertical resolution of the encoded video. + /// Optional. The maximum horizontal resolution of the encoded video. + /// Optional. The maximum vertical resolution of the encoded video. + /// Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults. + /// Optional. The index of the subtitle stream to use. If omitted no subtitles will be used. + /// Optional. Specify the subtitle delivery method. + /// Optional. + /// Optional. The maximum video bit depth. + /// Optional. Whether to require avc. + /// Optional. Whether to deinterlace the video. + /// Optional. Whether to require a non anamorphic stream. + /// Optional. The maximum number of audio channels to transcode. + /// Optional. The limit of how many cpu cores to use. + /// The live stream id. + /// Optional. Whether to enable the MpegtsM2Ts mode. + /// 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. + /// Optional. Specify a subtitle codec to encode to. + /// Optional. The transcoding reason. + /// Optional. The index of the audio stream to use. If omitted the first audio stream will be used. + /// Optional. The index of the video stream to use. If omitted the first video stream will be used. + /// Optional. The . + /// Optional. The streaming options. + /// Enable adaptive bitrate streaming. + /// Video stream returned. + /// A containing the playlist file. + [HttpGet("Videos/{itemId}/master.m3u8")] + [HttpHead("Videos/{itemId}/master.m3u8", Name = "HeadMasterHlsVideoPlaylist")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesPlaylistFile] + public async Task 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 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 + }; + + return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false); + } + + /// + /// Gets an audio hls playlist stream. + /// + /// The item id. + /// 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. + /// The streaming parameters. + /// The tag. + /// Optional. The dlna device profile id to utilize. + /// The play session id. + /// The segment container. + /// The segment length. + /// The minimum number of segments. + /// The media version id, if playing an alternate version. + /// The device id of the client requesting. Used to stop encoding processes when needed. + /// 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. + /// Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true. + /// Whether or not to allow copying of the video stream url. + /// Whether or not to allow copying of the audio stream url. + /// Optional. Whether to break on non key frames. + /// Optional. Specify a specific audio sample rate, e.g. 44100. + /// Optional. The maximum audio bit depth. + /// Optional. The maximum streaming bitrate. + /// Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults. + /// Optional. Specify a specific number of audio channels to encode to, e.g. 2. + /// Optional. Specify a maximum number of audio channels to encode to, e.g. 2. + /// Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high. + /// Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1. + /// Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements. + /// Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements. + /// Whether or not to copy timestamps when transcoding with an offset. Defaults to false. + /// Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms. + /// Optional. The fixed horizontal resolution of the encoded video. + /// Optional. The fixed vertical resolution of the encoded video. + /// Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults. + /// Optional. The index of the subtitle stream to use. If omitted no subtitles will be used. + /// Optional. Specify the subtitle delivery method. + /// Optional. + /// Optional. The maximum video bit depth. + /// Optional. Whether to require avc. + /// Optional. Whether to deinterlace the video. + /// Optional. Whether to require a non anamorphic stream. + /// Optional. The maximum number of audio channels to transcode. + /// Optional. The limit of how many cpu cores to use. + /// The live stream id. + /// Optional. Whether to enable the MpegtsM2Ts mode. + /// 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. + /// Optional. Specify a subtitle codec to encode to. + /// Optional. The transcoding reason. + /// Optional. The index of the audio stream to use. If omitted the first audio stream will be used. + /// Optional. The index of the video stream to use. If omitted the first video stream will be used. + /// Optional. The . + /// Optional. The streaming options. + /// Enable adaptive bitrate streaming. + /// Audio stream returned. + /// A containing the playlist file. + [HttpGet("Audio/{itemId}/master.m3u8")] + [HttpHead("Audio/{itemId}/master.m3u8", Name = "HeadMasterHlsAudioPlaylist")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesPlaylistFile] + public async Task 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 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 + }; + + return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false); + } + + /// + /// Gets a video stream using HTTP live streaming. + /// + /// The item id. + /// 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. + /// The streaming parameters. + /// The tag. + /// Optional. The dlna device profile id to utilize. + /// The play session id. + /// The segment container. + /// The segment length. + /// The minimum number of segments. + /// The media version id, if playing an alternate version. + /// The device id of the client requesting. Used to stop encoding processes when needed. + /// 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. + /// Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true. + /// Whether or not to allow copying of the video stream url. + /// Whether or not to allow copying of the audio stream url. + /// Optional. Whether to break on non key frames. + /// Optional. Specify a specific audio sample rate, e.g. 44100. + /// Optional. The maximum audio bit depth. + /// Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults. + /// Optional. Specify a specific number of audio channels to encode to, e.g. 2. + /// Optional. Specify a maximum number of audio channels to encode to, e.g. 2. + /// Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high. + /// Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1. + /// Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements. + /// Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements. + /// Whether or not to copy timestamps when transcoding with an offset. Defaults to false. + /// Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms. + /// Optional. The fixed horizontal resolution of the encoded video. + /// Optional. The fixed vertical resolution of the encoded video. + /// Optional. The maximum horizontal resolution of the encoded video. + /// Optional. The maximum vertical resolution of the encoded video. + /// Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults. + /// Optional. The index of the subtitle stream to use. If omitted no subtitles will be used. + /// Optional. Specify the subtitle delivery method. + /// Optional. + /// Optional. The maximum video bit depth. + /// Optional. Whether to require avc. + /// Optional. Whether to deinterlace the video. + /// Optional. Whether to require a non anamorphic stream. + /// Optional. The maximum number of audio channels to transcode. + /// Optional. The limit of how many cpu cores to use. + /// The live stream id. + /// Optional. Whether to enable the MpegtsM2Ts mode. + /// 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. + /// Optional. Specify a subtitle codec to encode to. + /// Optional. The transcoding reason. + /// Optional. The index of the audio stream to use. If omitted the first audio stream will be used. + /// Optional. The index of the video stream to use. If omitted the first video stream will be used. + /// Optional. The . + /// Optional. The streaming options. + /// Video stream returned. + /// A containing the audio file. + [HttpGet("Videos/{itemId}/main.m3u8")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesPlaylistFile] + public async Task 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 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); + } + + /// + /// Gets an audio stream using HTTP live streaming. + /// + /// The item id. + /// 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. + /// The streaming parameters. + /// The tag. + /// Optional. The dlna device profile id to utilize. + /// The play session id. + /// The segment container. + /// The segment length. + /// The minimum number of segments. + /// The media version id, if playing an alternate version. + /// The device id of the client requesting. Used to stop encoding processes when needed. + /// 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. + /// Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true. + /// Whether or not to allow copying of the video stream url. + /// Whether or not to allow copying of the audio stream url. + /// Optional. Whether to break on non key frames. + /// Optional. Specify a specific audio sample rate, e.g. 44100. + /// Optional. The maximum audio bit depth. + /// Optional. The maximum streaming bitrate. + /// Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults. + /// Optional. Specify a specific number of audio channels to encode to, e.g. 2. + /// Optional. Specify a maximum number of audio channels to encode to, e.g. 2. + /// Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high. + /// Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1. + /// Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements. + /// Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements. + /// Whether or not to copy timestamps when transcoding with an offset. Defaults to false. + /// Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms. + /// Optional. The fixed horizontal resolution of the encoded video. + /// Optional. The fixed vertical resolution of the encoded video. + /// Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults. + /// Optional. The index of the subtitle stream to use. If omitted no subtitles will be used. + /// Optional. Specify the subtitle delivery method. + /// Optional. + /// Optional. The maximum video bit depth. + /// Optional. Whether to require avc. + /// Optional. Whether to deinterlace the video. + /// Optional. Whether to require a non anamorphic stream. + /// Optional. The maximum number of audio channels to transcode. + /// Optional. The limit of how many cpu cores to use. + /// The live stream id. + /// Optional. Whether to enable the MpegtsM2Ts mode. + /// 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. + /// Optional. Specify a subtitle codec to encode to. + /// Optional. The transcoding reason. + /// Optional. The index of the audio stream to use. If omitted the first audio stream will be used. + /// Optional. The index of the video stream to use. If omitted the first video stream will be used. + /// Optional. The . + /// Optional. The streaming options. + /// Audio stream returned. + /// A containing the audio file. + [HttpGet("Audio/{itemId}/main.m3u8")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesPlaylistFile] + public async Task 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 streamOptions) + { + 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 + }; + + return await GetVariantPlaylistInternal(streamingRequest, cancellationTokenSource) + .ConfigureAwait(false); + } + + /// + /// Gets a video stream using HTTP live streaming. + /// + /// The item id. + /// The playlist id. + /// The segment id. + /// The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. + /// The position of the requested segment in ticks. + /// The length of the requested segment in ticks. + /// 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. + /// The streaming parameters. + /// The tag. + /// Optional. The dlna device profile id to utilize. + /// The play session id. + /// The segment container. + /// The desired segment length. + /// The minimum number of segments. + /// The media version id, if playing an alternate version. + /// The device id of the client requesting. Used to stop encoding processes when needed. + /// 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. + /// Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true. + /// Whether or not to allow copying of the video stream url. + /// Whether or not to allow copying of the audio stream url. + /// Optional. Whether to break on non key frames. + /// Optional. Specify a specific audio sample rate, e.g. 44100. + /// Optional. The maximum audio bit depth. + /// Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults. + /// Optional. Specify a specific number of audio channels to encode to, e.g. 2. + /// Optional. Specify a maximum number of audio channels to encode to, e.g. 2. + /// Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high. + /// Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1. + /// Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements. + /// Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements. + /// Whether or not to copy timestamps when transcoding with an offset. Defaults to false. + /// Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms. + /// Optional. The fixed horizontal resolution of the encoded video. + /// Optional. The fixed vertical resolution of the encoded video. + /// Optional. The maximum horizontal resolution of the encoded video. + /// Optional. The maximum vertical resolution of the encoded video. + /// Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults. + /// Optional. The index of the subtitle stream to use. If omitted no subtitles will be used. + /// Optional. Specify the subtitle delivery method. + /// Optional. + /// Optional. The maximum video bit depth. + /// Optional. Whether to require avc. + /// Optional. Whether to deinterlace the video. + /// Optional. Whether to require a non anamorphic stream. + /// Optional. The maximum number of audio channels to transcode. + /// Optional. The limit of how many cpu cores to use. + /// The live stream id. + /// Optional. Whether to enable the MpegtsM2Ts mode. + /// 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. + /// Optional. Specify a subtitle codec to encode to. + /// Optional. The transcoding reason. + /// Optional. The index of the audio stream to use. If omitted the first audio stream will be used. + /// Optional. The index of the video stream to use. If omitted the first video stream will be used. + /// Optional. The . + /// Optional. The streaming options. + /// Video stream returned. + /// A containing the audio file. + [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 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 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); + } + + /// + /// Gets a video stream using HTTP live streaming. + /// + /// The item id. + /// The playlist id. + /// The segment id. + /// The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. + /// The position of the requested segment in ticks. + /// The length of the requested segment in ticks. + /// 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. + /// The streaming parameters. + /// The tag. + /// Optional. The dlna device profile id to utilize. + /// The play session id. + /// The segment container. + /// The segment length. + /// The minimum number of segments. + /// The media version id, if playing an alternate version. + /// The device id of the client requesting. Used to stop encoding processes when needed. + /// 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. + /// Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true. + /// Whether or not to allow copying of the video stream url. + /// Whether or not to allow copying of the audio stream url. + /// Optional. Whether to break on non key frames. + /// Optional. Specify a specific audio sample rate, e.g. 44100. + /// Optional. The maximum audio bit depth. + /// Optional. The maximum streaming bitrate. + /// Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults. + /// Optional. Specify a specific number of audio channels to encode to, e.g. 2. + /// Optional. Specify a maximum number of audio channels to encode to, e.g. 2. + /// Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high. + /// Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1. + /// Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements. + /// Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements. + /// Whether or not to copy timestamps when transcoding with an offset. Defaults to false. + /// Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms. + /// Optional. The fixed horizontal resolution of the encoded video. + /// Optional. The fixed vertical resolution of the encoded video. + /// Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults. + /// Optional. The index of the subtitle stream to use. If omitted no subtitles will be used. + /// Optional. Specify the subtitle delivery method. + /// Optional. + /// Optional. The maximum video bit depth. + /// Optional. Whether to require avc. + /// Optional. Whether to deinterlace the video. + /// Optional. Whether to require a non anamorphic stream. + /// Optional. The maximum number of audio channels to transcode. + /// Optional. The limit of how many cpu cores to use. + /// The live stream id. + /// Optional. Whether to enable the MpegtsM2Ts mode. + /// 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. + /// Optional. Specify a subtitle codec to encode to. + /// Optional. The transcoding reason. + /// Optional. The index of the audio stream to use. If omitted the first audio stream will be used. + /// Optional. The index of the video stream to use. If omitted the first video stream will be used. + /// Optional. The . + /// Optional. The streaming options. + /// Video stream returned. + /// A containing the audio file. + [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 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 streamOptions) + { + 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 + }; + + return await GetDynamicSegment(streamingRequest, segmentId) + .ConfigureAwait(false); + } + + private async Task 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 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); + + var playlistPath = Path.ChangeExtension(state.OutputFilePath, ".m3u8"); + + var segmentPath = GetSegmentPath(state, playlistPath, segmentId); + + var segmentExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer); + + TranscodingJobDto? job; + + 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); } - private static double[] GetSegmentLengths(StreamState state) - => GetSegmentLengthsInternal(state.RunTimeTicks ?? 0, state.SegmentLength); + var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlistPath); + await transcodingLock.WaitAsync(cancellationToken).ConfigureAwait(false); + var released = false; + var startTranscoding = false; - internal static double[] GetSegmentLengthsInternal(long runtimeTicks, int segmentlength) + try { - 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 (System.IO.File.Exists(segmentPath)) { - segments[i] = segmentlength; - } - - if (remainingTicks != 0) - { - segments[^1] = TimeSpan.FromTicks(remainingTicks).TotalSeconds; - } - - return segments; - } - - private string GetCommandLineArguments(string outputPath, StreamState state, bool isEventPlaylist, int startNumber) - { - var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions); - var threads = EncodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec); - - 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 mapArgs = state.IsOutputVideo ? _encodingHelper.GetMapArgs(state) : string.Empty; - - 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; - - 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 - { - // 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 + "\"" - }; - - segmentFormat = "fmp4" + outputFmp4HeaderArg; + 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); } else { - _logger.LogError("Invalid HLS segment container: {SegmentContainer}, default to mpegts", segmentContainer); - segmentFormat = "mpegts"; + 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; + } + + if (startTranscoding) + { + // 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; + + state.WaitForPath = segmentPath; + job = await _transcodingJobHelper.StartFfMpeg( + state, + playlistPath, + GetCommandLineArguments(playlistPath, state, false, segmentId), + Request, + TranscodingJobType, + cancellationTokenSource).ConfigureAwait(false); + } + catch + { + state.Dispose(); + throw; + } + + // await WaitForMinimumSegmentCount(playlistPath, 1, cancellationTokenSource.Token).ConfigureAwait(false); + } + else + { + job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); + if (job?.TranscodingThrottler is not null) + { + await job.TranscodingThrottler.UnpauseTranscoding().ConfigureAwait(false); + } + } } - - var maxMuxingQueueSize = _encodingOptions.MaxMuxingQueueSize > 128 - ? _encodingOptions.MaxMuxingQueueSize.ToString(CultureInfo.InvariantCulture) - : "128"; - - var baseUrlParam = string.Empty; - if (isEventPlaylist) + } + finally + { + if (!released) { - baseUrlParam = string.Format( - CultureInfo.InvariantCulture, - " -hls_base_url \"hls/{0}/\"", - Path.GetFileNameWithoutExtension(outputPath)); + transcodingLock.Release(); } - - 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", - outputTsArg, - outputPath).Trim(); } - /// - /// Gets the audio arguments for transcoding. - /// - /// The . - /// The command line arguments for audio transcoding. - private string GetAudioArguments(StreamState state) + _logger.LogDebug("returning {0} [general case]", segmentPath); + job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); + return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false); + } + + private static double[] GetSegmentLengths(StreamState state) + => GetSegmentLengthsInternal(state.RunTimeTicks ?? 0, state.SegmentLength); + + internal static double[] GetSegmentLengthsInternal(long runtimeTicks, int segmentlength) + { + var segmentLengthTicks = TimeSpan.FromSeconds(segmentlength).Ticks; + var wholeSegments = runtimeTicks / segmentLengthTicks; + var remainingTicks = runtimeTicks % segmentLengthTicks; + + var segmentsLen = wholeSegments + (remainingTicks == 0 ? 0 : 1); + var segments = new double[segmentsLen]; + for (int i = 0; i < wholeSegments; i++) { - if (state.AudioStream is null) + segments[i] = segmentlength; + } + + if (remainingTicks != 0) + { + segments[^1] = TimeSpan.FromTicks(remainingTicks).TotalSeconds; + } + + return segments; + } + + private string GetCommandLineArguments(string outputPath, StreamState state, bool isEventPlaylist, int startNumber) + { + var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions); + var threads = EncodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec); + + 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 mapArgs = state.IsOutputVideo ? _encodingHelper.GetMapArgs(state) : string.Empty; + + 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; + + 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 { - return string.Empty; - } + // 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 audioCodec = _encodingHelper.GetAudioEncoder(state); + segmentFormat = "fmp4" + outputFmp4HeaderArg; + } + else + { + _logger.LogError("Invalid HLS segment container: {SegmentContainer}, default to mpegts", segmentContainer); + segmentFormat = "mpegts"; + } - if (!state.IsOutputVideo) - { - if (EncodingHelper.IsCopyCodec(audioCodec)) - { - var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container); + var maxMuxingQueueSize = _encodingOptions.MaxMuxingQueueSize > 128 + ? _encodingOptions.MaxMuxingQueueSize.ToString(CultureInfo.InvariantCulture) + : "128"; - return "-acodec copy -strict -2" + bitStreamArgs; - } + var baseUrlParam = string.Empty; + if (isEventPlaylist) + { + baseUrlParam = string.Format( + CultureInfo.InvariantCulture, + " -hls_base_url \"hls/{0}/\"", + Path.GetFileNameWithoutExtension(outputPath)); + } - var audioTranscodeParams = string.Empty; + var hlsArguments = GetHlsArguments(isEventPlaylist, state.SegmentLength); - audioTranscodeParams += "-acodec " + audioCodec; + return string.Format( + CultureInfo.InvariantCulture, + "{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -copyts -avoid_negative_ts disabled -max_muxing_queue_size {6} -f hls -max_delay 5000000 -hls_time {7} -hls_segment_type {8} -start_number {9}{10} -hls_segment_filename \"{11}\" {12} -y \"{13}\"", + inputModifier, + _encodingHelper.GetInputArgument(state, _encodingOptions, segmentContainer), + threads, + mapArgs, + GetVideoArguments(state, startNumber, isEventPlaylist), + GetAudioArguments(state), + maxMuxingQueueSize, + state.SegmentLength.ToString(CultureInfo.InvariantCulture), + segmentFormat, + startNumber.ToString(CultureInfo.InvariantCulture), + baseUrlParam, + EncodingUtils.NormalizePath(outputTsArg), + hlsArguments, + EncodingUtils.NormalizePath(outputPath)).Trim(); + } - if (state.OutputAudioBitrate.HasValue) - { - audioTranscodeParams += " -ab " + state.OutputAudioBitrate.Value.ToString(CultureInfo.InvariantCulture); - } + /// + /// Gets the HLS arguments for transcoding. + /// + /// The command line arguments for HLS transcoding. + private string GetHlsArguments(bool isEventPlaylist, int segmentLength) + { + var enableThrottling = _encodingOptions.EnableThrottling; + var enableSegmentDeletion = _encodingOptions.EnableSegmentDeletion; - if (state.OutputAudioChannels.HasValue) - { - audioTranscodeParams += " -ac " + state.OutputAudioChannels.Value.ToString(CultureInfo.InvariantCulture); - } + // Only enable segment deletion when throttling is enabled + if (enableThrottling && enableSegmentDeletion) + { + // Store enough segments for configured seconds of playback; this needs to be above throttling settings + var segmentCount = _encodingOptions.SegmentKeepSeconds / segmentLength; - if (state.OutputAudioSampleRate.HasValue) - { - audioTranscodeParams += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture); - } + _logger.LogDebug("Using throttling and segment deletion, keeping {0} segments", segmentCount); - audioTranscodeParams += " -vn"; - return audioTranscodeParams; - } + return string.Format(CultureInfo.InvariantCulture, "-hls_list_size {0} -hls_flags delete_segments", segmentCount.ToString(CultureInfo.InvariantCulture)); + } + else + { + _logger.LogDebug("Using normal playback, is event playlist? {0}", isEventPlaylist); - // dts, flac, opus and truehd are experimental in mp4 muxer - var strictArgs = string.Empty; + return string.Format(CultureInfo.InvariantCulture, "-hls_playlist_type {0} -hls_list_size 0", isEventPlaylist ? "event" : "vod"); + } + } - 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"; - } + /// + /// Gets the audio arguments for transcoding. + /// + /// The . + /// The command line arguments for audio transcoding. + private string GetAudioArguments(StreamState state) + { + if (state.AudioStream is null) + { + return string.Empty; + } + var audioCodec = _encodingHelper.GetAudioEncoder(state); + + if (!state.IsOutputVideo) + { if (EncodingHelper.IsCopyCodec(audioCodec)) { - var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions); var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container); - var copyArgs = "-codec:a:0 copy" + bitStreamArgs + strictArgs; - if (EncodingHelper.IsCopyCodec(videoCodec) && state.EnableBreakOnNonKeyFrames(videoCodec)) + return "-acodec copy -strict -2" + bitStreamArgs; + } + + var audioTranscodeParams = string.Empty; + + audioTranscodeParams += "-acodec " + audioCodec; + + var audioBitrate = state.OutputAudioBitrate; + var audioChannels = state.OutputAudioChannels; + + if (audioBitrate.HasValue && !EncodingHelper.LosslessAudioCodecs.Contains(state.ActualOutputAudioCodec, StringComparison.OrdinalIgnoreCase)) + { + var vbrParam = _encodingHelper.GetAudioVbrModeParam(audioCodec, audioBitrate.Value / (audioChannels ?? 2)); + if (_encodingOptions.EnableAudioVbr && vbrParam is not null) { - return copyArgs + " -copypriorss:a:0 0"; + audioTranscodeParams += vbrParam; + } + else + { + audioTranscodeParams += " -ab " + audioBitrate.Value.ToString(CultureInfo.InvariantCulture); } - - return copyArgs; } - var args = "-codec:a:0 " + audioCodec + strictArgs; - - var channels = state.OutputAudioChannels; - - if (channels.HasValue - && (channels.Value != 2 - || (state.AudioStream is not null - && state.AudioStream.Channels.HasValue - && state.AudioStream.Channels.Value > 5 - && _encodingOptions.DownMixStereoAlgorithm == DownMixStereoAlgorithms.None))) + if (audioChannels.HasValue) { - args += " -ac " + channels.Value; - } - - var bitrate = state.OutputAudioBitrate; - - if (bitrate.HasValue) - { - args += " -ab " + bitrate.Value.ToString(CultureInfo.InvariantCulture); + audioTranscodeParams += " -ac " + audioChannels.Value.ToString(CultureInfo.InvariantCulture); } if (state.OutputAudioSampleRate.HasValue) { - args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture); + audioTranscodeParams += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture); } - args += _encodingHelper.GetAudioFilterParam(state, _encodingOptions); - - return args; + audioTranscodeParams += " -vn"; + return audioTranscodeParams; } - /// - /// Gets the video arguments for transcoding. - /// - /// The . - /// The first number in the hls sequence. - /// Whether the playlist is EVENT or VOD. - /// The command line arguments for video transcoding. - private string GetVideoArguments(StreamState state, int startNumber, bool isEventPlaylist) + // dts, flac, opus and truehd are experimental in mp4 muxer + var strictArgs = string.Empty; + var actualOutputAudioCodec = state.ActualOutputAudioCodec; + if (string.Equals(actualOutputAudioCodec, "flac", StringComparison.OrdinalIgnoreCase) + || string.Equals(actualOutputAudioCodec, "opus", StringComparison.OrdinalIgnoreCase) + || string.Equals(actualOutputAudioCodec, "dts", StringComparison.OrdinalIgnoreCase) + || string.Equals(actualOutputAudioCodec, "truehd", StringComparison.OrdinalIgnoreCase)) { - if (state.VideoStream is null) + strictArgs = " -strict -2"; + } + + if (EncodingHelper.IsCopyCodec(audioCodec)) + { + var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions); + var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container); + var copyArgs = "-codec:a:0 copy" + bitStreamArgs + strictArgs; + + if (EncodingHelper.IsCopyCodec(videoCodec) && state.EnableBreakOnNonKeyFrames(videoCodec)) { - return string.Empty; + return copyArgs + " -copypriorss:a:0 0"; } - if (!state.IsOutputVideo) + return copyArgs; + } + + var args = "-codec:a:0 " + audioCodec + strictArgs; + + var channels = state.OutputAudioChannels; + + if (channels.HasValue + && (channels.Value != 2 + || (state.AudioStream is not null + && state.AudioStream.Channels.HasValue + && state.AudioStream.Channels.Value > 5 + && _encodingOptions.DownMixStereoAlgorithm == DownMixStereoAlgorithms.None))) + { + args += " -ac " + channels.Value; + } + + var bitrate = state.OutputAudioBitrate; + if (bitrate.HasValue && !EncodingHelper.LosslessAudioCodecs.Contains(actualOutputAudioCodec, StringComparison.OrdinalIgnoreCase)) + { + var vbrParam = _encodingHelper.GetAudioVbrModeParam(audioCodec, bitrate.Value / (channels ?? 2)); + if (_encodingOptions.EnableAudioVbr && vbrParam is not null) { - return string.Empty; - } - - 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 (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))) - { - // Prefer dvh1 to dvhe - args += " -tag:v:0 dvh1 -strict -2"; - } - else - { - // Prefer hvc1 to hev1 - args += " -tag:v:0 hvc1"; - } - } - - // if (state.EnableMpegtsM2TsMode) - // { - // args += " -mpegts_m2ts_mode 1"; - // } - - // 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; - } - } - - args += " -start_at_zero"; + args += vbrParam; } else { - args += _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, isEventPlaylist ? DefaultEventEncoderPreset : DefaultVodEncoderPreset); + args += " -ab " + bitrate.Value.ToString(CultureInfo.InvariantCulture); + } + } - // Set the key frame params for video encoding to match the hls segment time. - args += _encodingHelper.GetHlsVideoKeyFrameArguments(state, codec, state.SegmentLength, isEventPlaylist, startNumber); + if (state.OutputAudioSampleRate.HasValue) + { + args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture); + } - // 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.GetAudioFilterParam(state, _encodingOptions); + + return args; + } + + /// + /// Gets the video arguments for transcoding. + /// + /// The . + /// The first number in the hls sequence. + /// Whether the playlist is EVENT or VOD. + /// The command line arguments for video transcoding. + private string GetVideoArguments(StreamState state, int startNumber, bool isEventPlaylist) + { + if (state.VideoStream is null) + { + return string.Empty; + } + + if (!state.IsOutputVideo) + { + return string.Empty; + } + + 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 (EncodingHelper.IsCopyCodec(codec) + && (state.VideoStream.VideoRangeType == VideoRangeType.DOVI + || string.Equals(state.VideoStream.CodecTag, "dovi", StringComparison.OrdinalIgnoreCase) + || string.Equals(state.VideoStream.CodecTag, "dvh1", StringComparison.OrdinalIgnoreCase) + || string.Equals(state.VideoStream.CodecTag, "dvhe", StringComparison.OrdinalIgnoreCase))) + { + // Prefer dvh1 to dvhe + args += " -tag:v:0 dvh1 -strict -2"; + } + else + { + // Prefer hvc1 to hev1 + args += " -tag:v:0 hvc1"; + } + } + + // if (state.EnableMpegtsM2TsMode) + // { + // args += " -mpegts_m2ts_mode 1"; + // } + + // 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 += " -bf 0"; + args += " " + bitStreamArgs; } + } - // args += " -mixed-refs 0 -refs 3 -x264opts b_pyramid=0:weightb=0:weightp=0"; + args += " -start_at_zero"; + } + else + { + args += _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, isEventPlaylist ? DefaultEventEncoderPreset : DefaultVodEncoderPreset); - // video processing filters. - args += _encodingHelper.GetVideoProcessingFilterParam(state, _encodingOptions, codec); + // Set the key frame params for video encoding to match the hls segment time. + args += _encodingHelper.GetHlsVideoKeyFrameArguments(state, codec, state.SegmentLength, isEventPlaylist, startNumber); - // -start_at_zero is necessary to use with -ss when seeking, - // otherwise the target position cannot be determined. - if (state.SubtitleStream is not null) + // 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"; + } + + // args += " -mixed-refs 0 -refs 3 -x264opts b_pyramid=0:weightb=0:weightp=0"; + + // video processing filters. + var videoProcessParam = _encodingHelper.GetVideoProcessingFilterParam(state, _encodingOptions, codec); + + var negativeMapArgs = _encodingHelper.GetNegativeMapArgsByFilters(state, videoProcessParam); + + 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) + { + // 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"; + } + } + } + + // TODO why was this not enabled for VOD? + if (isEventPlaylist) + { + args += " -flags -global_header"; + } + + if (!string.IsNullOrEmpty(state.OutputVideoSync)) + { + args += " -vsync " + state.OutputVideoSync; + } + + args += _encodingHelper.GetOutputFFlags(state); + + 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); + + return Path.Combine(folder, filename + index.ToString(CultureInfo.InvariantCulture) + EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer)); + } + + private async Task GetSegmentResult( + StreamState state, + string playlistPath, + string segmentPath, + string segmentExtension, + int segmentIndex, + TranscodingJobDto? transcodingJob, + CancellationToken cancellationToken) + { + var segmentExists = System.IO.File.Exists(segmentPath); + if (segmentExists) + { + 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); + } + + 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); + } + } + + var nextSegmentPath = GetSegmentPath(state, playlistPath, segmentIndex + 1); + if (transcodingJob is not null) + { + 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) + { + if (transcodingJob.HasExited || System.IO.File.Exists(nextSegmentPath)) { - args += " -start_at_zero"; + _logger.LogDebug("Serving up {SegmentPath} as it deemed ready", segmentPath); + return GetSegmentResult(state, segmentPath, transcodingJob); } } - } - - // TODO why was this not enabled for VOD? - if (isEventPlaylist) - { - args += " -flags -global_header"; - } - - if (!string.IsNullOrEmpty(state.OutputVideoSync)) - { - args += " -vsync " + state.OutputVideoSync; - } - - args += _encodingHelper.GetOutputFFlags(state); - - 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); - - return Path.Combine(folder, filename + index.ToString(CultureInfo.InvariantCulture) + EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer)); - } - - private async Task GetSegmentResult( - StreamState state, - string playlistPath, - string segmentPath, - string segmentExtension, - int segmentIndex, - TranscodingJobDto? transcodingJob, - CancellationToken cancellationToken) - { - var segmentExists = System.IO.File.Exists(segmentPath); - if (segmentExists) - { - if (transcodingJob is not null && transcodingJob.HasExited) + else { - // 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); - - // 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) - { - 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 + segmentExists = System.IO.File.Exists(segmentPath); 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); - } + continue; // avoid unnecessary waiting if segment just became available } - else - { - segmentExists = System.IO.File.Exists(segmentPath); - if (segmentExists) - { - continue; // avoid unnecessary waiting if segment just became available - } - } - - await Task.Delay(100, cancellationToken).ConfigureAwait(false); } - if (!System.IO.File.Exists(segmentPath)) - { - _logger.LogWarning("cannot serve {0} as transcoding quit before we got there", segmentPath); - } - else - { - _logger.LogDebug("serving {0} as it's on disk and transcoding stopped", segmentPath); - } + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } - cancellationToken.ThrowIfCancellationRequested(); + 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(); + } + else + { + _logger.LogWarning("cannot serve {0} as it doesn't exist and no transcode is running", segmentPath); } - private ActionResult GetSegmentResult(StreamState state, string segmentPath, TranscodingJobDto? transcodingJob) + return GetSegmentResult(state, segmentPath, transcodingJob); + } + + private ActionResult GetSegmentResult(StreamState state, string segmentPath, TranscodingJobDto? transcodingJob) + { + var segmentEndingPositionTicks = state.Request.CurrentRuntimeTicks + state.Request.ActualSegmentLengthTicks; + + Response.OnCompleted(() => { - var segmentEndingPositionTicks = state.Request.CurrentRuntimeTicks + state.Request.ActualSegmentLengthTicks; - - Response.OnCompleted(() => + _logger.LogDebug("Finished serving {SegmentPath}", segmentPath); + if (transcodingJob is not null) { - _logger.LogDebug("Finished serving {SegmentPath}", segmentPath); - if (transcodingJob is not null) - { - transcodingJob.DownloadPositionTicks = Math.Max(transcodingJob.DownloadPositionTicks ?? segmentEndingPositionTicks, segmentEndingPositionTicks); - _transcodingJobHelper.OnTranscodeEndRequest(transcodingJob); - } + transcodingJob.DownloadPositionTicks = Math.Max(transcodingJob.DownloadPositionTicks ?? segmentEndingPositionTicks, segmentEndingPositionTicks); + _transcodingJobHelper.OnTranscodeEndRequest(transcodingJob); + } - return Task.CompletedTask; - }); + return Task.CompletedTask; + }); - return FileStreamResponseHelpers.GetStaticFileResult(segmentPath, MimeTypes.GetMimeType(segmentPath)); + return FileStreamResponseHelpers.GetStaticFileResult(segmentPath, MimeTypes.GetMimeType(segmentPath)); + } + + private int? GetCurrentTranscodingIndex(string playlist, string segmentExtension) + { + var job = _transcodingJobHelper.GetTranscodingJob(playlist, TranscodingJobType); + + if (job is null || job.HasExited) + { + return null; } - private int? GetCurrentTranscodingIndex(string playlist, string segmentExtension) + var file = GetLastTranscodingFile(playlist, segmentExtension, _fileSystem); + + if (file is null) { - var job = _transcodingJobHelper.GetTranscodingJob(playlist, TranscodingJobType); - - if (job is null || job.HasExited) - { - return null; - } - - var file = GetLastTranscodingFile(playlist, segmentExtension, _fileSystem); - - if (file is null) - { - return null; - } - - var playlistFilename = Path.GetFileNameWithoutExtension(playlist); - - var indexString = Path.GetFileNameWithoutExtension(file.Name).Substring(playlistFilename.Length); - - return int.Parse(indexString, NumberStyles.Integer, CultureInfo.InvariantCulture); + return null; } - private static FileSystemMetadata? GetLastTranscodingFile(string playlist, string segmentExtension, IFileSystem fileSystem) + var playlistFilename = Path.GetFileNameWithoutExtension(playlist); + + 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)); + + var filePrefix = Path.GetFileNameWithoutExtension(playlist); + + try { - var folder = Path.GetDirectoryName(playlist) ?? throw new ArgumentException("Path can't be a root directory.", nameof(playlist)); + return fileSystem.GetFiles(folder, new[] { segmentExtension }, true, false) + .Where(i => Path.GetFileNameWithoutExtension(i.Name).StartsWith(filePrefix, StringComparison.OrdinalIgnoreCase)) + .MaxBy(fileSystem.GetLastWriteTimeUtc); + } + catch (IOException) + { + return null; + } + } - var filePrefix = Path.GetFileNameWithoutExtension(playlist); + private void DeleteLastFile(string playlistPath, string segmentExtension, int retryCount) + { + var file = GetLastTranscodingFile(playlistPath, segmentExtension, _fileSystem); - 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; - } + if (file is not null) + { + DeleteFile(file.FullName, retryCount); + } + } + + private void DeleteFile(string path, int retryCount) + { + if (retryCount >= 5) + { + return; } - private void DeleteLastFile(string playlistPath, string segmentExtension, int retryCount) - { - var file = GetLastTranscodingFile(playlistPath, segmentExtension, _fileSystem); + _logger.LogDebug("Deleting partial HLS file {Path}", path); - if (file is not null) - { - DeleteFile(file.FullName, retryCount); - } + try + { + _fileSystem.DeleteFile(path); } - - private void DeleteFile(string path, int retryCount) + catch (IOException ex) { - if (retryCount >= 5) - { - return; - } + _logger.LogError(ex, "Error deleting partial stream file(s) {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); - - var task = Task.Delay(100); - task.Wait(); - DeleteFile(path, retryCount + 1); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path); - } + var task = Task.Delay(100); + task.Wait(); + DeleteFile(path, retryCount + 1); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path); } } } diff --git a/Jellyfin.Api/Controllers/EnvironmentController.cs b/Jellyfin.Api/Controllers/EnvironmentController.cs index 6c78a79875..8c9ee1a19e 100644 --- a/Jellyfin.Api/Controllers/EnvironmentController.cs +++ b/Jellyfin.Api/Controllers/EnvironmentController.cs @@ -12,186 +12,185 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// Environment Controller. +/// +[Authorize(Policy = Policies.FirstTimeSetupOrElevated)] +public class EnvironmentController : BaseJellyfinApiController { + private const char UncSeparator = '\\'; + private const string UncStartPrefix = @"\\"; + + private readonly IFileSystem _fileSystem; + private readonly ILogger _logger; + /// - /// Environment Controller. + /// Initializes a new instance of the class. /// - [Authorize(Policy = Policies.FirstTimeSetupOrElevated)] - public class EnvironmentController : BaseJellyfinApiController + /// Instance of the interface. + /// Instance of the interface. + public EnvironmentController(IFileSystem fileSystem, ILogger logger) { - private const char UncSeparator = '\\'; - private const string UncStartPrefix = @"\\"; + _fileSystem = fileSystem; + _logger = logger; + } - private readonly IFileSystem _fileSystem; - private readonly ILogger _logger; - - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - public EnvironmentController(IFileSystem fileSystem, ILogger logger) + /// + /// Gets the contents of a given directory in the file system. + /// + /// The path. + /// An optional filter to include or exclude files from the results. true/false. + /// An optional filter to include or exclude folders from the results. true/false. + /// Directory contents returned. + /// Directory contents. + [HttpGet("DirectoryContents")] + [ProducesResponseType(StatusCodes.Status200OK)] + public IEnumerable GetDirectoryContents( + [FromQuery, Required] string path, + [FromQuery] bool includeFiles = false, + [FromQuery] bool includeDirectories = false) + { + if (path.StartsWith(UncStartPrefix, StringComparison.OrdinalIgnoreCase) + && path.LastIndexOf(UncSeparator) == 1) { - _fileSystem = fileSystem; - _logger = logger; + return Array.Empty(); } - /// - /// Gets the contents of a given directory in the file system. - /// - /// The path. - /// An optional filter to include or exclude files from the results. true/false. - /// An optional filter to include or exclude folders from the results. true/false. - /// Directory contents returned. - /// Directory contents. - [HttpGet("DirectoryContents")] - [ProducesResponseType(StatusCodes.Status200OK)] - public IEnumerable GetDirectoryContents( - [FromQuery, Required] string path, - [FromQuery] bool includeFiles = false, - [FromQuery] bool includeDirectories = false) + 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)); + } + + /// + /// Validates path. + /// + /// Validate request object. + /// Path validated. + /// Path not found. + /// Validation status. + [HttpPost("ValidatePath")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult ValidatePath([FromBody, Required] ValidatePathDto validatePathDto) + { + if (validatePathDto.IsFile.HasValue) { - if (path.StartsWith(UncStartPrefix, StringComparison.OrdinalIgnoreCase) - && path.LastIndexOf(UncSeparator) == 1) + if (validatePathDto.IsFile.Value) { - return Array.Empty(); - } - - 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)); - } - - /// - /// Validates path. - /// - /// Validate request object. - /// Path validated. - /// Path not found. - /// Validation status. - [HttpPost("ValidatePath")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult ValidatePath([FromBody, Required] ValidatePathDto validatePathDto) - { - if (validatePathDto.IsFile.HasValue) - { - 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(); } - - if (validatePathDto.ValidateWritable) - { - if (validatePathDto.Path is null) - { - throw new ResourceNotFoundException(nameof(validatePathDto.Path)); - } - - var file = Path.Combine(validatePathDto.Path, Guid.NewGuid().ToString()); - try - { - System.IO.File.WriteAllText(file, string.Empty); - } - finally - { - if (System.IO.File.Exists(file)) - { - System.IO.File.Delete(file); - } - } - } } - - return NoContent(); } - - /// - /// Gets network paths. - /// - /// Empty array returned. - /// List of entries. - [Obsolete("This endpoint is obsolete.")] - [HttpGet("NetworkShares")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> GetNetworkShares() + else { - _logger.LogWarning("Obsolete endpoint accessed: /Environment/NetworkShares"); - return Array.Empty(); - } - - /// - /// Gets available drives from the server's file system. - /// - /// List of entries returned. - /// List of entries. - [HttpGet("Drives")] - [ProducesResponseType(StatusCodes.Status200OK)] - public IEnumerable GetDrives() - { - return _fileSystem.GetDrives().Select(d => new FileSystemEntryInfo(d.Name, d.FullName, FileSystemEntryType.Directory)); - } - - /// - /// Gets the parent path of a given path. - /// - /// The path. - /// Parent path. - [HttpGet("ParentPath")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult GetParentPath([FromQuery, Required] string path) - { - string? parent = Path.GetDirectoryName(path); - if (string.IsNullOrEmpty(parent)) + if (!System.IO.File.Exists(validatePathDto.Path) && !Directory.Exists(validatePathDto.Path)) { - // Check if unc share - var index = path.LastIndexOf(UncSeparator); + return NotFound(); + } - if (index != -1 && path.IndexOf(UncSeparator, StringComparison.OrdinalIgnoreCase) == 0) + if (validatePathDto.ValidateWritable) + { + if (validatePathDto.Path is null) { - parent = path.Substring(0, index); + throw new ResourceNotFoundException(nameof(validatePathDto.Path)); + } - if (string.IsNullOrWhiteSpace(parent.TrimStart(UncSeparator))) + var file = Path.Combine(validatePathDto.Path, Guid.NewGuid().ToString()); + try + { + System.IO.File.WriteAllText(file, string.Empty); + } + finally + { + if (System.IO.File.Exists(file)) { - parent = null; + System.IO.File.Delete(file); } } } - - return parent; } - /// - /// Get Default directory browser. - /// - /// Default directory browser returned. - /// Default directory browser. - [HttpGet("DefaultDirectoryBrowser")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult GetDefaultDirectoryBrowser() + return NoContent(); + } + + /// + /// Gets network paths. + /// + /// Empty array returned. + /// List of entries. + [Obsolete("This endpoint is obsolete.")] + [HttpGet("NetworkShares")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> GetNetworkShares() + { + _logger.LogWarning("Obsolete endpoint accessed: /Environment/NetworkShares"); + return Array.Empty(); + } + + /// + /// Gets available drives from the server's file system. + /// + /// List of entries returned. + /// List of entries. + [HttpGet("Drives")] + [ProducesResponseType(StatusCodes.Status200OK)] + public IEnumerable GetDrives() + { + return _fileSystem.GetDrives().Select(d => new FileSystemEntryInfo(d.Name, d.FullName, FileSystemEntryType.Directory)); + } + + /// + /// Gets the parent path of a given path. + /// + /// The path. + /// Parent path. + [HttpGet("ParentPath")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult GetParentPath([FromQuery, Required] string path) + { + string? parent = Path.GetDirectoryName(path); + if (string.IsNullOrEmpty(parent)) { - return new DefaultDirectoryBrowserInfoDto(); + // Check if unc share + var index = path.LastIndexOf(UncSeparator); + + if (index != -1 && path.IndexOf(UncSeparator, StringComparison.OrdinalIgnoreCase) == 0) + { + parent = path.Substring(0, index); + + if (string.IsNullOrWhiteSpace(parent.TrimStart(UncSeparator))) + { + parent = null; + } + } } + + return parent; + } + + /// + /// Get Default directory browser. + /// + /// Default directory browser returned. + /// Default directory browser. + [HttpGet("DefaultDirectoryBrowser")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult GetDefaultDirectoryBrowser() + { + return new DefaultDirectoryBrowserInfoDto(); } } diff --git a/Jellyfin.Api/Controllers/FilterController.cs b/Jellyfin.Api/Controllers/FilterController.cs index 17d136384e..d51a5325f5 100644 --- a/Jellyfin.Api/Controllers/FilterController.cs +++ b/Jellyfin.Api/Controllers/FilterController.cs @@ -1,6 +1,6 @@ using System; using System.Linq; -using Jellyfin.Api.Constants; +using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Dto; @@ -12,205 +12,206 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// Filters controller. +/// +[Route("")] +[Authorize] +public class FilterController : BaseJellyfinApiController { + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + /// - /// Filters controller. + /// Initializes a new instance of the class. /// - [Route("")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class FilterController : BaseJellyfinApiController + /// Instance of the interface. + /// Instance of the interface. + public FilterController(ILibraryManager libraryManager, IUserManager userManager) { - private readonly ILibraryManager _libraryManager; - private readonly IUserManager _userManager; + _libraryManager = libraryManager; + _userManager = userManager; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - public FilterController(ILibraryManager libraryManager, IUserManager userManager) + /// + /// Gets legacy query filters. + /// + /// Optional. User id. + /// Optional. Parent id. + /// Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited. + /// Optional. Filter by MediaType. Allows multiple, comma delimited. + /// Legacy filters retrieved. + /// Legacy query filters. + [HttpGet("Items/Filters")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult 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); } - /// - /// Gets legacy query filters. - /// - /// Optional. User id. - /// Optional. Parent id. - /// Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited. - /// Optional. Filter by MediaType. Allows multiple, comma delimited. - /// Legacy filters retrieved. - /// Legacy query filters. - [HttpGet("Items/Filters")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult 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)) + User = user, + MediaTypes = mediaTypes, + IncludeItemTypes = includeItemTypes, + Recursive = true, + EnableTotalRecordCount = false, + DtoOptions = new DtoOptions { - item = _libraryManager.GetParentItem(parentId, user?.Id); + Fields = new[] { ItemFields.Genres, ItemFields.Tags }, + EnableImages = false, + EnableUserData = false } + }; - 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) - { - return new QueryFiltersLegacy(); - } - - 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(); } - /// - /// Gets query filters. - /// - /// Optional. User id. - /// Optional. Specify this to localize the search to a specific item or folder. Omit to use the root. - /// Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited. - /// Optional. Is item airing. - /// Optional. Is item movie. - /// Optional. Is item sports. - /// Optional. Is item kids. - /// Optional. Is item news. - /// Optional. Is item series. - /// Optional. Search recursive. - /// Filters retrieved. - /// Query filters. - [HttpGet("Items/Filters2")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult 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); + Years = itemList.Select(i => i.ProductionYear ?? -1) + .Where(i => i > 0) + .Distinct() + .Order() + .ToArray(), - 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); - } + Genres = itemList.SelectMany(i => i.Genres) + .DistinctNames() + .Order() + .ToArray(), - var filters = new QueryFilters(); - var genreQuery = new InternalItemsQuery(user) - { - IncludeItemTypes = includeItemTypes, - DtoOptions = new DtoOptions - { - Fields = Array.Empty(), - EnableImages = false, - EnableUserData = false - }, - IsAiring = isAiring, - IsMovie = isMovie, - IsSports = isSports, - IsKids = isKids, - IsNews = isNews, - IsSeries = isSeries - }; + Tags = itemList + .SelectMany(i => i.Tags) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Order() + .ToArray(), - if ((recursive ?? true) || parentItem is UserView || parentItem is ICollectionFolder) - { - genreQuery.AncestorIds = parentItem is null ? Array.Empty() : new[] { parentItem.Id }; - } - else - { - genreQuery.Parent = parentItem; - } + OfficialRatings = itemList + .Select(i => i.OfficialRating) + .Where(i => !string.IsNullOrWhiteSpace(i)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Order() + .ToArray() + }; + } - 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 - { - Name = i.Item.Name, - Id = i.Item.Id - }).ToArray(); - } - else - { - filters.Genres = _libraryManager.GetGenres(genreQuery).Items.Select(i => new NameGuidPair - { - Name = i.Item.Name, - Id = i.Item.Id - }).ToArray(); - } + /// + /// Gets query filters. + /// + /// Optional. User id. + /// Optional. Specify this to localize the search to a specific item or folder. Omit to use the root. + /// Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited. + /// Optional. Is item airing. + /// Optional. Is item movie. + /// Optional. Is item sports. + /// Optional. Is item kids. + /// Optional. Is item news. + /// Optional. Is item series. + /// Optional. Search recursive. + /// Filters retrieved. + /// Query filters. + [HttpGet("Items/Filters2")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult 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); - return filters; + 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 + { + Fields = Array.Empty(), + 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() : 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)) + { + filters.Genres = _libraryManager.GetMusicGenres(genreQuery).Items.Select(i => new NameGuidPair + { + Name = i.Item.Name, + Id = i.Item.Id + }).ToArray(); + } + else + { + filters.Genres = _libraryManager.GetGenres(genreQuery).Items.Select(i => new NameGuidPair + { + Name = i.Item.Name, + Id = i.Item.Id + }).ToArray(); + } + + return filters; } } diff --git a/Jellyfin.Api/Controllers/GenresController.cs b/Jellyfin.Api/Controllers/GenresController.cs index 611643bd8a..da60f2c60b 100644 --- a/Jellyfin.Api/Controllers/GenresController.cs +++ b/Jellyfin.Api/Controllers/GenresController.cs @@ -1,7 +1,6 @@ using System; using System.ComponentModel.DataAnnotations; using System.Linq; -using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; @@ -18,194 +17,192 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Genre = MediaBrowser.Controller.Entities.Genre; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// The genres controller. +/// +[Authorize] +public class GenresController : BaseJellyfinApiController { + private readonly IUserManager _userManager; + private readonly ILibraryManager _libraryManager; + private readonly IDtoService _dtoService; + /// - /// The genres controller. + /// Initializes a new instance of the class. /// - [Authorize(Policy = Policies.DefaultAuthorization)] - public class GenresController : BaseJellyfinApiController + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + public GenresController( + IUserManager userManager, + ILibraryManager libraryManager, + IDtoService dtoService) { - private readonly IUserManager _userManager; - private readonly ILibraryManager _libraryManager; - private readonly IDtoService _dtoService; + _userManager = userManager; + _libraryManager = libraryManager; + _dtoService = dtoService; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - public GenresController( - IUserManager userManager, - ILibraryManager libraryManager, - IDtoService dtoService) + /// + /// Gets all genres from a given item, folder, or the entire library. + /// + /// Optional. The record index to start at. All items with a lower index will be dropped from the results. + /// Optional. The maximum number of records to return. + /// The search term. + /// Specify this to localize the search to a specific item or folder. Omit to use the root. + /// Optional. Specify additional fields of information to return in the output. + /// Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited. + /// Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited. + /// Optional filter by items that are marked as favorite, or not. + /// Optional, the max number of images to return, per image type. + /// Optional. The image types to include in the output. + /// User id. + /// Optional filter by items whose name is sorted equally or greater than a given input string. + /// Optional filter by items whose name is sorted equally than a given input string. + /// Optional filter by items whose name is equally or lesser than a given input string. + /// Optional. Specify one or more sort orders, comma delimited. + /// Sort Order - Ascending,Descending. + /// Optional, include image information in output. + /// Optional. Include total record count. + /// Genres returned. + /// An containing the queryresult of genres. + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> 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.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + + var parentItem = _libraryManager.GetParentItem(parentId, userId); + + var query = new InternalItemsQuery(user) { - _userManager = userManager; - _libraryManager = libraryManager; - _dtoService = dtoService; + 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 }; + } } - /// - /// Gets all genres from a given item, folder, or the entire library. - /// - /// Optional. The record index to start at. All items with a lower index will be dropped from the results. - /// Optional. The maximum number of records to return. - /// The search term. - /// Specify this to localize the search to a specific item or folder. Omit to use the root. - /// Optional. Specify additional fields of information to return in the output. - /// Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited. - /// Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited. - /// Optional filter by items that are marked as favorite, or not. - /// Optional, the max number of images to return, per image type. - /// Optional. The image types to include in the output. - /// User id. - /// Optional filter by items whose name is sorted equally or greater than a given input string. - /// Optional filter by items whose name is sorted equally than a given input string. - /// Optional filter by items whose name is equally or lesser than a given input string. - /// Optional. Specify one or more sort orders, comma delimited. - /// Sort Order - Ascending,Descending. - /// Optional, include image information in output. - /// Optional. Include total record count. - /// Genres returned. - /// An containing the queryresult of genres. - [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> 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); - - User? user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - - 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) + 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))) - { - result = _libraryManager.GetMusicGenres(query); - } - else - { - result = _libraryManager.GetGenres(query); - } - - var shouldIncludeItemTypes = includeItemTypes.Length != 0; - return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user); - } - - /// - /// Gets a genre, by name. - /// - /// The genre name. - /// The user id. - /// Genres returned. - /// An containing the genre. - [HttpGet("{genreName}")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult GetGenre([FromRoute, Required] string genreName, [FromQuery] Guid? userId) { - var dtoOptions = new DtoOptions() - .AddClientFields(User); - - Genre? item; - if (genreName.Contains(BaseItem.SlugChar, StringComparison.OrdinalIgnoreCase)) - { - item = GetItemFromSlugName(_libraryManager, genreName, dtoOptions, BaseItemKind.Genre); - } - else - { - item = _libraryManager.GetGenre(genreName); - } - - item ??= new Genre(); - - if (userId is null || userId.Value.Equals(default)) - { - return _dtoService.GetBaseItemDto(item, dtoOptions); - } - - var user = _userManager.GetUserById(userId.Value); - - return _dtoService.GetBaseItemDto(item, dtoOptions, user); + result = _libraryManager.GetMusicGenres(query); } - - private T? GetItemFromSlugName(ILibraryManager libraryManager, string name, DtoOptions dtoOptions, BaseItemKind baseItemKind) - where T : BaseItem, new() + else { - var result = libraryManager.GetItemList(new InternalItemsQuery - { - Name = name.Replace(BaseItem.SlugChar, '&'), - IncludeItemTypes = new[] { baseItemKind }, - DtoOptions = dtoOptions - }).OfType().FirstOrDefault(); - - result ??= libraryManager.GetItemList(new InternalItemsQuery - { - Name = name.Replace(BaseItem.SlugChar, '/'), - IncludeItemTypes = new[] { baseItemKind }, - DtoOptions = dtoOptions - }).OfType().FirstOrDefault(); - - result ??= libraryManager.GetItemList(new InternalItemsQuery - { - Name = name.Replace(BaseItem.SlugChar, '?'), - IncludeItemTypes = new[] { baseItemKind }, - DtoOptions = dtoOptions - }).OfType().FirstOrDefault(); - - return result; + result = _libraryManager.GetGenres(query); } + + var shouldIncludeItemTypes = includeItemTypes.Length != 0; + return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user); + } + + /// + /// Gets a genre, by name. + /// + /// The genre name. + /// The user id. + /// Genres returned. + /// An containing the genre. + [HttpGet("{genreName}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult GetGenre([FromRoute, Required] string genreName, [FromQuery] Guid? userId) + { + userId = RequestHelpers.GetUserId(User, userId); + var dtoOptions = new DtoOptions() + .AddClientFields(User); + + Genre? item; + if (genreName.Contains(BaseItem.SlugChar, StringComparison.OrdinalIgnoreCase)) + { + item = GetItemFromSlugName(_libraryManager, genreName, dtoOptions, BaseItemKind.Genre); + } + else + { + item = _libraryManager.GetGenre(genreName); + } + + item ??= new Genre(); + + var user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + + return _dtoService.GetBaseItemDto(item, dtoOptions, user); + } + + private T? GetItemFromSlugName(ILibraryManager libraryManager, string name, DtoOptions dtoOptions, BaseItemKind baseItemKind) + where T : BaseItem, new() + { + var result = libraryManager.GetItemList(new InternalItemsQuery + { + Name = name.Replace(BaseItem.SlugChar, '&'), + IncludeItemTypes = new[] { baseItemKind }, + DtoOptions = dtoOptions + }).OfType().FirstOrDefault(); + + result ??= libraryManager.GetItemList(new InternalItemsQuery + { + Name = name.Replace(BaseItem.SlugChar, '/'), + IncludeItemTypes = new[] { baseItemKind }, + DtoOptions = dtoOptions + }).OfType().FirstOrDefault(); + + result ??= libraryManager.GetItemList(new InternalItemsQuery + { + Name = name.Replace(BaseItem.SlugChar, '?'), + IncludeItemTypes = new[] { baseItemKind }, + DtoOptions = dtoOptions + }).OfType().FirstOrDefault(); + + return result; } } diff --git a/Jellyfin.Api/Controllers/HlsSegmentController.cs b/Jellyfin.Api/Controllers/HlsSegmentController.cs index 50fee233a8..d7cec865e1 100644 --- a/Jellyfin.Api/Controllers/HlsSegmentController.cs +++ b/Jellyfin.Api/Controllers/HlsSegmentController.cs @@ -4,7 +4,6 @@ using System.Diagnostics.CodeAnalysis; using System.IO; using System.Threading.Tasks; using Jellyfin.Api.Attributes; -using Jellyfin.Api.Constants; using Jellyfin.Api.Helpers; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Configuration; @@ -15,178 +14,177 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// The hls segment controller. +/// +[Route("")] +public class HlsSegmentController : BaseJellyfinApiController { + private readonly IFileSystem _fileSystem; + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly TranscodingJobHelper _transcodingJobHelper; + /// - /// The hls segment controller. + /// Initializes a new instance of the class. /// - [Route("")] - public class HlsSegmentController : BaseJellyfinApiController + /// Instance of the interface. + /// Instance of the interface. + /// Initialized instance of the . + public HlsSegmentController( + IFileSystem fileSystem, + IServerConfigurationManager serverConfigurationManager, + TranscodingJobHelper transcodingJobHelper) { - private readonly IFileSystem _fileSystem; - private readonly IServerConfigurationManager _serverConfigurationManager; - private readonly TranscodingJobHelper _transcodingJobHelper; + _fileSystem = fileSystem; + _serverConfigurationManager = serverConfigurationManager; + _transcodingJobHelper = transcodingJobHelper; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - /// Initialized instance of the . - public HlsSegmentController( - IFileSystem fileSystem, - IServerConfigurationManager serverConfigurationManager, - TranscodingJobHelper transcodingJobHelper) + /// + /// Gets the specified audio segment for an audio item. + /// + /// The item id. + /// The segment id. + /// Hls audio segment returned. + /// A containing the audio stream. + // 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)) { - _fileSystem = fileSystem; - _serverConfigurationManager = serverConfigurationManager; - _transcodingJobHelper = transcodingJobHelper; + return BadRequest("Invalid segment."); } - /// - /// Gets the specified audio segment for an audio item. - /// - /// The item id. - /// The segment id. - /// Hls audio segment returned. - /// A containing the audio stream. - // 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) + return FileStreamResponseHelpers.GetStaticFileResult(file, MimeTypes.GetMimeType(file)); + } + + /// + /// Gets a hls video playlist. + /// + /// The video id. + /// The playlist id. + /// Hls video playlist returned. + /// A containing the playlist. + [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") { - // 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 GetFileResult(file, file); + } + + /// + /// Stops an active encoding. + /// + /// The device id of the client requesting. Used to stop encoding processes when needed. + /// The play session id. + /// Encoding stopped successfully. + /// A indicating success. + [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(); + } + + /// + /// Gets a hls video segment. + /// + /// The item id. + /// The playlist id. + /// The segment id. + /// The segment container. + /// Hls video segment returned. + /// Hls segment not found. + /// A containing the video segment. + // 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(); + + file = Path.GetFullPath(Path.Combine(transcodeFolderPath, file)); + var fileDir = Path.GetDirectoryName(file); + if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodeFolderPath, StringComparison.InvariantCulture)) + { + return BadRequest("Invalid segment."); + } + + 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) { - return BadRequest("Invalid segment."); + playlistPath = path; + break; + } + } + + 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); + + Response.OnCompleted(() => + { + if (transcodingJob is not null) + { + _transcodingJobHelper.OnTranscodeEndRequest(transcodingJob); } - return FileStreamResponseHelpers.GetStaticFileResult(file, MimeTypes.GetMimeType(file)); - } + return Task.CompletedTask; + }); - /// - /// Gets a hls video playlist. - /// - /// The video id. - /// The playlist id. - /// Hls video playlist returned. - /// A containing the playlist. - [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); - } - - /// - /// Stops an active encoding. - /// - /// The device id of the client requesting. Used to stop encoding processes when needed. - /// The play session id. - /// Encoding stopped successfully. - /// A indicating success. - [HttpDelete("Videos/ActiveEncodings")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult StopEncodingProcess( - [FromQuery, Required] string deviceId, - [FromQuery, Required] string playSessionId) - { - _transcodingJobHelper.KillTranscodingJobs(deviceId, playSessionId, path => true); - return NoContent(); - } - - /// - /// Gets a hls video segment. - /// - /// The item id. - /// The playlist id. - /// The segment id. - /// The segment container. - /// Hls video segment returned. - /// Hls segment not found. - /// A containing the video segment. - // 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(); - - file = Path.GetFullPath(Path.Combine(transcodeFolderPath, file)); - var fileDir = Path.GetDirectoryName(file); - if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodeFolderPath, StringComparison.InvariantCulture)) - { - return BadRequest("Invalid segment."); - } - - 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); - } - - private ActionResult GetFileResult(string path, string playlistPath) - { - var transcodingJob = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType.Hls); - - Response.OnCompleted(() => - { - if (transcodingJob is not null) - { - _transcodingJobHelper.OnTranscodeEndRequest(transcodingJob); - } - - return Task.CompletedTask; - }); - - return FileStreamResponseHelpers.GetStaticFileResult(path, MimeTypes.GetMimeType(path)); - } + return FileStreamResponseHelpers.GetStaticFileResult(path, MimeTypes.GetMimeType(path)); } } diff --git a/Jellyfin.Api/Controllers/ImageController.cs b/Jellyfin.Api/Controllers/ImageController.cs index 996dc08196..3c5f18af55 100644 --- a/Jellyfin.Api/Controllers/ImageController.cs +++ b/Jellyfin.Api/Controllers/ImageController.cs @@ -30,2071 +30,2115 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Microsoft.Net.Http.Headers; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// Image controller. +/// +[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 _logger; + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly IApplicationPaths _appPaths; + /// - /// Image controller. + /// Initializes a new instance of the class. /// - [Route("")] - public class ImageController : BaseJellyfinApiController + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + public ImageController( + IUserManager userManager, + ILibraryManager libraryManager, + IProviderManager providerManager, + IImageProcessor imageProcessor, + IFileSystem fileSystem, + ILogger 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 _logger; - private readonly IServerConfigurationManager _serverConfigurationManager; - private readonly IApplicationPaths _appPaths; + _userManager = userManager; + _libraryManager = libraryManager; + _providerManager = providerManager; + _imageProcessor = imageProcessor; + _fileSystem = fileSystem; + _logger = logger; + _serverConfigurationManager = serverConfigurationManager; + _appPaths = appPaths; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - public ImageController( - IUserManager userManager, - ILibraryManager libraryManager, - IProviderManager providerManager, - IImageProcessor imageProcessor, - IFileSystem fileSystem, - ILogger logger, - IServerConfigurationManager serverConfigurationManager, - IApplicationPaths appPaths) + /// + /// Sets the user image. + /// + /// User Id. + /// (Unused) Image type. + /// (Unused) Image index. + /// Image updated. + /// User does not have permission to delete the image. + /// A . + [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 PostUserImage( + [FromRoute, Required] Guid userId, + [FromRoute, Required] ImageType imageType, + [FromQuery] int? index = null) + { + var user = _userManager.GetUserById(userId); + if (user is null) { - _userManager = userManager; - _libraryManager = libraryManager; - _providerManager = providerManager; - _imageProcessor = imageProcessor; - _fileSystem = fileSystem; - _logger = logger; - _serverConfigurationManager = serverConfigurationManager; - _appPaths = appPaths; + return NotFound(); } - /// - /// Sets the user image. - /// - /// User Id. - /// (Unused) Image type. - /// (Unused) Image index. - /// Image updated. - /// User does not have permission to delete the image. - /// A . - [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 PostUserImage( - [FromRoute, Required] Guid userId, - [FromRoute, Required] ImageType imageType, - [FromQuery] int? index = null) + if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true)) { - 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); - } - - 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); - - return NoContent(); - } + return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the image."); } - /// - /// Sets the user image. - /// - /// User Id. - /// (Unused) Image type. - /// (Unused) Image index. - /// Image updated. - /// User does not have permission to delete the image. - /// A . - [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 PostUserImageByIndex( - [FromRoute, Required] Guid userId, - [FromRoute, Required] ImageType imageType, - [FromRoute] int index) + if (!TryGetImageExtensionFromContentType(Request.ContentType, out string? extension)) { - 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); - } - - 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); - - return NoContent(); - } + return BadRequest("Incorrect ContentType."); } - /// - /// Delete the user's image. - /// - /// User Id. - /// (Unused) Image type. - /// (Unused) Image index. - /// Image deleted. - /// User does not have permission to delete the image. - /// A . - [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 DeleteUserImage( - [FromRoute, Required] Guid userId, - [FromRoute, Required] ImageType imageType, - [FromQuery] int? index = null) + var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false); + await using (memoryStream.ConfigureAwait(false)) { - if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true)) + // 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 delete the image."); + await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false); } - var user = _userManager.GetUserById(userId); - if (user?.ProfileImage is null) + 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(); + } + } + + /// + /// Sets the user image. + /// + /// User Id. + /// (Unused) Image type. + /// (Unused) Image index. + /// Image updated. + /// User does not have permission to delete the image. + /// A . + [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 PostUserImageByIndex( + [FromRoute, Required] Guid userId, + [FromRoute, Required] ImageType imageType, + [FromRoute] int index) + { + var user = _userManager.GetUserById(userId); + if (user is null) + { + return NotFound(); + } + + if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true)) + { + return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the image."); + } + + if (!TryGetImageExtensionFromContentType(Request.ContentType, out string? extension)) + { + 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(); + var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username); + if (user.ProfileImage is not null) { - return NoContent(); + await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false); } - try - { - System.IO.File.Delete(user.ProfileImage.Path); - } - catch (IOException e) - { - _logger.LogError(e, "Error deleting user profile image:"); - } + user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + extension)); - await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false); + await _providerManager + .SaveImage(memoryStream, mimeType, user.ProfileImage.Path) + .ConfigureAwait(false); + await _userManager.UpdateUserAsync(user).ConfigureAwait(false); + + return NoContent(); + } + } + + /// + /// Delete the user's image. + /// + /// User Id. + /// (Unused) Image type. + /// (Unused) Image index. + /// Image deleted. + /// User does not have permission to delete the image. + /// A . + [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 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(); } - /// - /// Delete the user's image. - /// - /// User Id. - /// (Unused) Image type. - /// (Unused) Image index. - /// Image deleted. - /// User does not have permission to delete the image. - /// A . - [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 DeleteUserImageByIndex( - [FromRoute, Required] Guid userId, - [FromRoute, Required] ImageType imageType, - [FromRoute] int index) + try { - if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true)) - { - return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to delete the image."); - } + System.IO.File.Delete(user.ProfileImage.Path); + } + catch (IOException e) + { + _logger.LogError(e, "Error deleting user profile image:"); + } - var user = _userManager.GetUserById(userId); - if (user?.ProfileImage is null) - { - return NoContent(); - } + await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false); + return NoContent(); + } - try - { - System.IO.File.Delete(user.ProfileImage.Path); - } - catch (IOException e) - { - _logger.LogError(e, "Error deleting user profile image:"); - } + /// + /// Delete the user's image. + /// + /// User Id. + /// (Unused) Image type. + /// (Unused) Image index. + /// Image deleted. + /// User does not have permission to delete the image. + /// A . + [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 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(); } - /// - /// Delete an item's image. - /// - /// Item id. - /// Image type. - /// The image index. - /// Image deleted. - /// Item not found. - /// A on success, or a if item not found. - [HttpDelete("Items/{itemId}/Images/{imageType}")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task DeleteItemImage( - [FromRoute, Required] Guid itemId, - [FromRoute, Required] ImageType imageType, - [FromQuery] int? imageIndex) + try { - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - return NotFound(); - } + 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(); + } + + /// + /// Delete an item's image. + /// + /// Item id. + /// Image type. + /// The image index. + /// Image deleted. + /// Item not found. + /// A on success, or a if item not found. + [HttpDelete("Items/{itemId}/Images/{imageType}")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeleteItemImage( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] ImageType imageType, + [FromQuery] int? imageIndex) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + return NotFound(); + } + + await item.DeleteImageAsync(imageType, imageIndex ?? 0).ConfigureAwait(false); + return NoContent(); + } + + /// + /// Delete an item's image. + /// + /// Item id. + /// Image type. + /// The image index. + /// Image deleted. + /// Item not found. + /// A on success, or a if item not found. + [HttpDelete("Items/{itemId}/Images/{imageType}/{imageIndex}")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task 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).ConfigureAwait(false); + return NoContent(); + } + + /// + /// Set item image. + /// + /// Item id. + /// Image type. + /// Image saved. + /// Item not found. + /// A on success, or a if item not found. + [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 SetItemImage( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] ImageType imageType) + { + 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.DeleteImageAsync(imageType, imageIndex ?? 0).ConfigureAwait(false); return NoContent(); } + } - /// - /// Delete an item's image. - /// - /// Item id. - /// Image type. - /// The image index. - /// Image deleted. - /// Item not found. - /// A on success, or a if item not found. - [HttpDelete("Items/{itemId}/Images/{imageType}/{imageIndex}")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task DeleteItemImageByIndex( - [FromRoute, Required] Guid itemId, - [FromRoute, Required] ImageType imageType, - [FromRoute] int imageIndex) + /// + /// Set item image. + /// + /// Item id. + /// Image type. + /// (Unused) Image index. + /// Image saved. + /// Item not found. + /// A on success, or a if item not found. + [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 SetItemImageByIndex( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] ImageType imageType, + [FromRoute] int imageIndex) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) { - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - return NotFound(); - } + 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.DeleteImageAsync(imageType, imageIndex).ConfigureAwait(false); return NoContent(); } + } - /// - /// Set item image. - /// - /// Item id. - /// Image type. - /// Image saved. - /// Item not found. - /// A on success, or a if item not found. - [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 SetItemImage( - [FromRoute, Required] Guid itemId, - [FromRoute, Required] ImageType imageType) + /// + /// Updates the index for an item image. + /// + /// Item id. + /// Image type. + /// Old image index. + /// New image index. + /// Image index updated. + /// Item not found. + /// A on success, or a if item not found. + [HttpPost("Items/{itemId}/Images/{imageType}/{imageIndex}/Index")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task 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) { - 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); - - return NoContent(); - } + return NotFound(); } - /// - /// Set item image. - /// - /// Item id. - /// Image type. - /// (Unused) Image index. - /// Image saved. - /// Item not found. - /// A on success, or a if item not found. - [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 SetItemImageByIndex( - [FromRoute, Required] Guid itemId, - [FromRoute, Required] ImageType imageType, - [FromRoute] int imageIndex) + await item.SwapImagesAsync(imageType, imageIndex, newIndex).ConfigureAwait(false); + return NoContent(); + } + + /// + /// Get item image infos. + /// + /// Item id. + /// Item images returned. + /// Item not found. + /// The list of image infos on success, or if item not found. + [HttpGet("Items/{itemId}/Images")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetItemImageInfos([FromRoute, Required] Guid itemId) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) { - 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); - - return NoContent(); - } + return NotFound(); } - /// - /// Updates the index for an item image. - /// - /// Item id. - /// Image type. - /// Old image index. - /// New image index. - /// Image index updated. - /// Item not found. - /// A on success, or a if item not found. - [HttpPost("Items/{itemId}/Images/{imageType}/{imageIndex}/Index")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task UpdateItemImageIndex( - [FromRoute, Required] Guid itemId, - [FromRoute, Required] ImageType imageType, - [FromRoute, Required] int imageIndex, - [FromQuery, Required] int newIndex) + var list = new List(); + var itemImages = item.ImageInfos; + + if (itemImages.Length == 0) { - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - return NotFound(); - } - - await item.SwapImagesAsync(imageType, imageIndex, newIndex).ConfigureAwait(false); - return NoContent(); - } - - /// - /// Get item image infos. - /// - /// Item id. - /// Item images returned. - /// Item not found. - /// The list of image infos on success, or if item not found. - [HttpGet("Items/{itemId}/Images")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task>> GetItemImageInfos([FromRoute, Required] Guid itemId) - { - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - return NotFound(); - } - - var list = new List(); - var itemImages = item.ImageInfos; - - 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)) - { - var info = GetImageInfo(item, image, null); - - 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 image in itemImages.Where(i => i.Type == currentImageType)) - { - var info = GetImageInfo(item, image, index); - - if (info is not null) - { - list.Add(info); - } - - index++; - } - } - + // short-circuit return list; } - /// - /// Gets the item's image. - /// - /// Item id. - /// Image type. - /// The maximum image width to return. - /// The maximum image height to return. - /// The fixed image width to return. - /// The fixed image height to return. - /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases. - /// Width of box to fill. - /// Height of box to fill. - /// Optional. Supply the cache tag from the item object to receive strong caching headers. - /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art. - /// Optional. The of the returned image. - /// Optional. Percent to render for the percent played overlay. - /// Optional. Unplayed count overlay to render. - /// Optional. Blur image. - /// Optional. Apply a background color for transparent images. - /// Optional. Apply a foreground layer on top of the image. - /// Image index. - /// Image stream returned. - /// Item not found. - /// - /// A containing the file stream on success, - /// or a if item not found. - /// - [HttpGet("Items/{itemId}/Images/{imageType}")] - [HttpHead("Items/{itemId}/Images/{imageType}", Name = "HeadItemImage")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesImageFile] - public async Task 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) + await _libraryManager.UpdateImagesAsync(item).ConfigureAwait(false); // this makes sure dimensions and hashes are correct + + foreach (var image in itemImages) { - var item = _libraryManager.GetItemById(itemId); - if (item is null) + if (!item.AllowsMultipleImages(image.Type)) + { + var info = GetImageInfo(item, image, null); + + 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 image in itemImages.Where(i => i.Type == currentImageType)) + { + var info = GetImageInfo(item, image, index); + + if (info is not null) + { + list.Add(info); + } + + index++; + } + } + + return list; + } + + /// + /// Gets the item's image. + /// + /// Item id. + /// Image type. + /// The maximum image width to return. + /// The maximum image height to return. + /// The fixed image width to return. + /// The fixed image height to return. + /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases. + /// Width of box to fill. + /// Height of box to fill. + /// Optional. Supply the cache tag from the item object to receive strong caching headers. + /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art. + /// Optional. The of the returned image. + /// Optional. Percent to render for the percent played overlay. + /// Optional. Unplayed count overlay to render. + /// Optional. Blur image. + /// Optional. Apply a background color for transparent images. + /// Optional. Apply a foreground layer on top of the image. + /// Image index. + /// Image stream returned. + /// Item not found. + /// + /// A containing the file stream on success, + /// or a if item not found. + /// + [HttpGet("Items/{itemId}/Images/{imageType}")] + [HttpHead("Items/{itemId}/Images/{imageType}", Name = "HeadItemImage")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task GetItemImage( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] ImageType imageType, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery] string? tag, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] ImageFormat? format, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer, + [FromQuery] int? imageIndex) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + return NotFound(); + } + + return await GetImageInternal( + itemId, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + blur, + backgroundColor, + foregroundLayer, + item) + .ConfigureAwait(false); + } + + /// + /// Gets the item's image. + /// + /// Item id. + /// Image type. + /// Image index. + /// The maximum image width to return. + /// The maximum image height to return. + /// The fixed image width to return. + /// The fixed image height to return. + /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases. + /// Width of box to fill. + /// Height of box to fill. + /// Optional. Supply the cache tag from the item object to receive strong caching headers. + /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art. + /// Optional. The of the returned image. + /// Optional. Percent to render for the percent played overlay. + /// Optional. Unplayed count overlay to render. + /// Optional. Blur image. + /// Optional. Apply a background color for transparent images. + /// Optional. Apply a foreground layer on top of the image. + /// Image stream returned. + /// Item not found. + /// + /// A containing the file stream on success, + /// or a if item not found. + /// + [HttpGet("Items/{itemId}/Images/{imageType}/{imageIndex}")] + [HttpHead("Items/{itemId}/Images/{imageType}/{imageIndex}", Name = "HeadItemImageByIndex")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task GetItemImageByIndex( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] ImageType imageType, + [FromRoute] int imageIndex, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery] string? tag, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] ImageFormat? format, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + return NotFound(); + } + + return await GetImageInternal( + itemId, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + blur, + backgroundColor, + foregroundLayer, + item) + .ConfigureAwait(false); + } + + /// + /// Gets the item's image. + /// + /// Item id. + /// Image type. + /// The maximum image width to return. + /// The maximum image height to return. + /// The fixed image width to return. + /// The fixed image height to return. + /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases. + /// Width of box to fill. + /// Height of box to fill. + /// Optional. Supply the cache tag from the item object to receive strong caching headers. + /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art. + /// Determines the output format of the image - original,gif,jpg,png. + /// Optional. Percent to render for the percent played overlay. + /// Optional. Unplayed count overlay to render. + /// Optional. Blur image. + /// Optional. Apply a background color for transparent images. + /// Optional. Apply a foreground layer on top of the image. + /// Image index. + /// Image stream returned. + /// Item not found. + /// + /// A containing the file stream on success, + /// or a if item not found. + /// + [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 GetItemImage2( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] ImageType imageType, + [FromRoute, Required] int maxWidth, + [FromRoute, Required] int maxHeight, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromRoute, Required] string tag, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromRoute, Required] ImageFormat format, + [FromRoute, Required] double percentPlayed, + [FromRoute, Required] int unplayedCount, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer, + [FromRoute, Required] int imageIndex) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + return NotFound(); + } + + return await GetImageInternal( + itemId, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + blur, + backgroundColor, + foregroundLayer, + item) + .ConfigureAwait(false); + } + + /// + /// Get artist image by name. + /// + /// Artist name. + /// Image type. + /// Optional. Supply the cache tag from the item object to receive strong caching headers. + /// Determines the output format of the image - original,gif,jpg,png. + /// The maximum image width to return. + /// The maximum image height to return. + /// Optional. Percent to render for the percent played overlay. + /// Optional. Unplayed count overlay to render. + /// The fixed image width to return. + /// The fixed image height to return. + /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases. + /// Width of box to fill. + /// Height of box to fill. + /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art. + /// Optional. Blur image. + /// Optional. Apply a background color for transparent images. + /// Optional. Apply a foreground layer on top of the image. + /// Image index. + /// Image stream returned. + /// Item not found. + /// + /// A containing the file stream on success, + /// or a if item not found. + /// + [HttpGet("Artists/{name}/Images/{imageType}/{imageIndex}")] + [HttpHead("Artists/{name}/Images/{imageType}/{imageIndex}", Name = "HeadArtistImage")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task GetArtistImage( + [FromRoute, Required] string name, + [FromRoute, Required] ImageType imageType, + [FromQuery] string? tag, + [FromQuery] ImageFormat? format, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer, + [FromRoute, Required] int imageIndex) + { + var item = _libraryManager.GetArtist(name); + if (item is null) + { + return NotFound(); + } + + return await GetImageInternal( + item.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + blur, + backgroundColor, + foregroundLayer, + item) + .ConfigureAwait(false); + } + + /// + /// Get genre image by name. + /// + /// Genre name. + /// Image type. + /// Optional. Supply the cache tag from the item object to receive strong caching headers. + /// Determines the output format of the image - original,gif,jpg,png. + /// The maximum image width to return. + /// The maximum image height to return. + /// Optional. Percent to render for the percent played overlay. + /// Optional. Unplayed count overlay to render. + /// The fixed image width to return. + /// The fixed image height to return. + /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases. + /// Width of box to fill. + /// Height of box to fill. + /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art. + /// Optional. Blur image. + /// Optional. Apply a background color for transparent images. + /// Optional. Apply a foreground layer on top of the image. + /// Image index. + /// Image stream returned. + /// Item not found. + /// + /// A containing the file stream on success, + /// or a if item not found. + /// + [HttpGet("Genres/{name}/Images/{imageType}")] + [HttpHead("Genres/{name}/Images/{imageType}", Name = "HeadGenreImage")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task GetGenreImage( + [FromRoute, Required] string name, + [FromRoute, Required] ImageType imageType, + [FromQuery] string? tag, + [FromQuery] ImageFormat? format, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer, + [FromQuery] int? imageIndex) + { + var item = _libraryManager.GetGenre(name); + if (item is null) + { + return NotFound(); + } + + return await GetImageInternal( + item.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + blur, + backgroundColor, + foregroundLayer, + item) + .ConfigureAwait(false); + } + + /// + /// Get genre image by name. + /// + /// Genre name. + /// Image type. + /// Image index. + /// Optional. Supply the cache tag from the item object to receive strong caching headers. + /// Determines the output format of the image - original,gif,jpg,png. + /// The maximum image width to return. + /// The maximum image height to return. + /// Optional. Percent to render for the percent played overlay. + /// Optional. Unplayed count overlay to render. + /// The fixed image width to return. + /// The fixed image height to return. + /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases. + /// Width of box to fill. + /// Height of box to fill. + /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art. + /// Optional. Blur image. + /// Optional. Apply a background color for transparent images. + /// Optional. Apply a foreground layer on top of the image. + /// Image stream returned. + /// Item not found. + /// + /// A containing the file stream on success, + /// or a if item not found. + /// + [HttpGet("Genres/{name}/Images/{imageType}/{imageIndex}")] + [HttpHead("Genres/{name}/Images/{imageType}/{imageIndex}", Name = "HeadGenreImageByIndex")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task GetGenreImageByIndex( + [FromRoute, Required] string name, + [FromRoute, Required] ImageType imageType, + [FromRoute, Required] int imageIndex, + [FromQuery] string? tag, + [FromQuery] ImageFormat? format, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer) + { + var item = _libraryManager.GetGenre(name); + if (item is null) + { + return NotFound(); + } + + return await GetImageInternal( + item.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + blur, + backgroundColor, + foregroundLayer, + item) + .ConfigureAwait(false); + } + + /// + /// Get music genre image by name. + /// + /// Music genre name. + /// Image type. + /// Optional. Supply the cache tag from the item object to receive strong caching headers. + /// Determines the output format of the image - original,gif,jpg,png. + /// The maximum image width to return. + /// The maximum image height to return. + /// Optional. Percent to render for the percent played overlay. + /// Optional. Unplayed count overlay to render. + /// The fixed image width to return. + /// The fixed image height to return. + /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases. + /// Width of box to fill. + /// Height of box to fill. + /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art. + /// Optional. Blur image. + /// Optional. Apply a background color for transparent images. + /// Optional. Apply a foreground layer on top of the image. + /// Image index. + /// Image stream returned. + /// Item not found. + /// + /// A containing the file stream on success, + /// or a if item not found. + /// + [HttpGet("MusicGenres/{name}/Images/{imageType}")] + [HttpHead("MusicGenres/{name}/Images/{imageType}", Name = "HeadMusicGenreImage")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task GetMusicGenreImage( + [FromRoute, Required] string name, + [FromRoute, Required] ImageType imageType, + [FromQuery] string? tag, + [FromQuery] ImageFormat? format, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer, + [FromQuery] int? imageIndex) + { + var item = _libraryManager.GetMusicGenre(name); + if (item is null) + { + return NotFound(); + } + + return await GetImageInternal( + item.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + blur, + backgroundColor, + foregroundLayer, + item) + .ConfigureAwait(false); + } + + /// + /// Get music genre image by name. + /// + /// Music genre name. + /// Image type. + /// Image index. + /// Optional. Supply the cache tag from the item object to receive strong caching headers. + /// Determines the output format of the image - original,gif,jpg,png. + /// The maximum image width to return. + /// The maximum image height to return. + /// Optional. Percent to render for the percent played overlay. + /// Optional. Unplayed count overlay to render. + /// The fixed image width to return. + /// The fixed image height to return. + /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases. + /// Width of box to fill. + /// Height of box to fill. + /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art. + /// Optional. Blur image. + /// Optional. Apply a background color for transparent images. + /// Optional. Apply a foreground layer on top of the image. + /// Image stream returned. + /// Item not found. + /// + /// A containing the file stream on success, + /// or a if item not found. + /// + [HttpGet("MusicGenres/{name}/Images/{imageType}/{imageIndex}")] + [HttpHead("MusicGenres/{name}/Images/{imageType}/{imageIndex}", Name = "HeadMusicGenreImageByIndex")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task GetMusicGenreImageByIndex( + [FromRoute, Required] string name, + [FromRoute, Required] ImageType imageType, + [FromRoute, Required] int imageIndex, + [FromQuery] string? tag, + [FromQuery] ImageFormat? format, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer) + { + var item = _libraryManager.GetMusicGenre(name); + if (item is null) + { + return NotFound(); + } + + return await GetImageInternal( + item.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + blur, + backgroundColor, + foregroundLayer, + item) + .ConfigureAwait(false); + } + + /// + /// Get person image by name. + /// + /// Person name. + /// Image type. + /// Optional. Supply the cache tag from the item object to receive strong caching headers. + /// Determines the output format of the image - original,gif,jpg,png. + /// The maximum image width to return. + /// The maximum image height to return. + /// Optional. Percent to render for the percent played overlay. + /// Optional. Unplayed count overlay to render. + /// The fixed image width to return. + /// The fixed image height to return. + /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases. + /// Width of box to fill. + /// Height of box to fill. + /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art. + /// Optional. Blur image. + /// Optional. Apply a background color for transparent images. + /// Optional. Apply a foreground layer on top of the image. + /// Image index. + /// Image stream returned. + /// Item not found. + /// + /// A containing the file stream on success, + /// or a if item not found. + /// + [HttpGet("Persons/{name}/Images/{imageType}")] + [HttpHead("Persons/{name}/Images/{imageType}", Name = "HeadPersonImage")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task GetPersonImage( + [FromRoute, Required] string name, + [FromRoute, Required] ImageType imageType, + [FromQuery] string? tag, + [FromQuery] ImageFormat? format, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer, + [FromQuery] int? imageIndex) + { + var item = _libraryManager.GetPerson(name); + if (item is null) + { + return NotFound(); + } + + return await GetImageInternal( + item.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + blur, + backgroundColor, + foregroundLayer, + item) + .ConfigureAwait(false); + } + + /// + /// Get person image by name. + /// + /// Person name. + /// Image type. + /// Image index. + /// Optional. Supply the cache tag from the item object to receive strong caching headers. + /// Determines the output format of the image - original,gif,jpg,png. + /// The maximum image width to return. + /// The maximum image height to return. + /// Optional. Percent to render for the percent played overlay. + /// Optional. Unplayed count overlay to render. + /// The fixed image width to return. + /// The fixed image height to return. + /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases. + /// Width of box to fill. + /// Height of box to fill. + /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art. + /// Optional. Blur image. + /// Optional. Apply a background color for transparent images. + /// Optional. Apply a foreground layer on top of the image. + /// Image stream returned. + /// Item not found. + /// + /// A containing the file stream on success, + /// or a if item not found. + /// + [HttpGet("Persons/{name}/Images/{imageType}/{imageIndex}")] + [HttpHead("Persons/{name}/Images/{imageType}/{imageIndex}", Name = "HeadPersonImageByIndex")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task GetPersonImageByIndex( + [FromRoute, Required] string name, + [FromRoute, Required] ImageType imageType, + [FromRoute, Required] int imageIndex, + [FromQuery] string? tag, + [FromQuery] ImageFormat? format, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer) + { + var item = _libraryManager.GetPerson(name); + if (item is null) + { + return NotFound(); + } + + return await GetImageInternal( + item.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + blur, + backgroundColor, + foregroundLayer, + item) + .ConfigureAwait(false); + } + + /// + /// Get studio image by name. + /// + /// Studio name. + /// Image type. + /// Optional. Supply the cache tag from the item object to receive strong caching headers. + /// Determines the output format of the image - original,gif,jpg,png. + /// The maximum image width to return. + /// The maximum image height to return. + /// Optional. Percent to render for the percent played overlay. + /// Optional. Unplayed count overlay to render. + /// The fixed image width to return. + /// The fixed image height to return. + /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases. + /// Width of box to fill. + /// Height of box to fill. + /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art. + /// Optional. Blur image. + /// Optional. Apply a background color for transparent images. + /// Optional. Apply a foreground layer on top of the image. + /// Image index. + /// Image stream returned. + /// Item not found. + /// + /// A containing the file stream on success, + /// or a if item not found. + /// + [HttpGet("Studios/{name}/Images/{imageType}")] + [HttpHead("Studios/{name}/Images/{imageType}", Name = "HeadStudioImage")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task GetStudioImage( + [FromRoute, Required] string name, + [FromRoute, Required] ImageType imageType, + [FromQuery] string? tag, + [FromQuery] ImageFormat? format, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer, + [FromQuery] int? imageIndex) + { + var item = _libraryManager.GetStudio(name); + if (item is null) + { + return NotFound(); + } + + return await GetImageInternal( + item.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + blur, + backgroundColor, + foregroundLayer, + item) + .ConfigureAwait(false); + } + + /// + /// Get studio image by name. + /// + /// Studio name. + /// Image type. + /// Image index. + /// Optional. Supply the cache tag from the item object to receive strong caching headers. + /// Determines the output format of the image - original,gif,jpg,png. + /// The maximum image width to return. + /// The maximum image height to return. + /// Optional. Percent to render for the percent played overlay. + /// Optional. Unplayed count overlay to render. + /// The fixed image width to return. + /// The fixed image height to return. + /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases. + /// Width of box to fill. + /// Height of box to fill. + /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art. + /// Optional. Blur image. + /// Optional. Apply a background color for transparent images. + /// Optional. Apply a foreground layer on top of the image. + /// Image stream returned. + /// Item not found. + /// + /// A containing the file stream on success, + /// or a if item not found. + /// + [HttpGet("Studios/{name}/Images/{imageType}/{imageIndex}")] + [HttpHead("Studios/{name}/Images/{imageType}/{imageIndex}", Name = "HeadStudioImageByIndex")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task GetStudioImageByIndex( + [FromRoute, Required] string name, + [FromRoute, Required] ImageType imageType, + [FromRoute, Required] int imageIndex, + [FromQuery] string? tag, + [FromQuery] ImageFormat? format, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer) + { + var item = _libraryManager.GetStudio(name); + if (item is null) + { + return NotFound(); + } + + return await GetImageInternal( + item.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + blur, + backgroundColor, + foregroundLayer, + item) + .ConfigureAwait(false); + } + + /// + /// Get user profile image. + /// + /// User id. + /// Image type. + /// Optional. Supply the cache tag from the item object to receive strong caching headers. + /// Determines the output format of the image - original,gif,jpg,png. + /// The maximum image width to return. + /// The maximum image height to return. + /// Optional. Percent to render for the percent played overlay. + /// Optional. Unplayed count overlay to render. + /// The fixed image width to return. + /// The fixed image height to return. + /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases. + /// Width of box to fill. + /// Height of box to fill. + /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art. + /// Optional. Blur image. + /// Optional. Apply a background color for transparent images. + /// Optional. Apply a foreground layer on top of the image. + /// Image index. + /// Image stream returned. + /// Item not found. + /// + /// A containing the file stream on success, + /// or a if item not found. + /// + [HttpGet("Users/{userId}/Images/{imageType}")] + [HttpHead("Users/{userId}/Images/{imageType}", Name = "HeadUserImage")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task GetUserImage( + [FromRoute, Required] Guid userId, + [FromRoute, Required] ImageType imageType, + [FromQuery] string? tag, + [FromQuery] ImageFormat? format, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer, + [FromQuery] int? imageIndex) + { + var user = _userManager.GetUserById(userId); + if (user?.ProfileImage is null) + { + return NotFound(); + } + + var info = new ItemImageInfo + { + Path = user.ProfileImage.Path, + Type = ImageType.Profile, + DateModified = user.ProfileImage.LastModified + }; + + if (width.HasValue) + { + info.Width = width.Value; + } + + if (height.HasValue) + { + info.Height = height.Value; + } + + 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); + } + + /// + /// Get user profile image. + /// + /// User id. + /// Image type. + /// Image index. + /// Optional. Supply the cache tag from the item object to receive strong caching headers. + /// Determines the output format of the image - original,gif,jpg,png. + /// The maximum image width to return. + /// The maximum image height to return. + /// Optional. Percent to render for the percent played overlay. + /// Optional. Unplayed count overlay to render. + /// The fixed image width to return. + /// The fixed image height to return. + /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases. + /// Width of box to fill. + /// Height of box to fill. + /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art. + /// Optional. Blur image. + /// Optional. Apply a background color for transparent images. + /// Optional. Apply a foreground layer on top of the image. + /// Image stream returned. + /// Item not found. + /// + /// A containing the file stream on success, + /// or a if item not found. + /// + [HttpGet("Users/{userId}/Images/{imageType}/{imageIndex}")] + [HttpHead("Users/{userId}/Images/{imageType}/{imageIndex}", Name = "HeadUserImageByIndex")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task 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(); + } + + var info = new ItemImageInfo + { + Path = user.ProfileImage.Path, + Type = ImageType.Profile, + DateModified = user.ProfileImage.LastModified + }; + + if (width.HasValue) + { + info.Width = width.Value; + } + + if (height.HasValue) + { + info.Height = height.Value; + } + + 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); + } + + /// + /// Generates or gets the splashscreen. + /// + /// Supply the cache tag from the item object to receive strong caching headers. + /// Determines the output format of the image - original,gif,jpg,png. + /// The maximum image width to return. + /// The maximum image height to return. + /// The fixed image width to return. + /// The fixed image height to return. + /// Width of box to fill. + /// Height of box to fill. + /// Blur image. + /// Apply a background color for transparent images. + /// Apply a foreground layer on top of the image. + /// Quality setting, from 0-100. + /// Splashscreen returned successfully. + /// The splashscreen. + [HttpGet("Branding/Splashscreen")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesImageFile] + public async Task 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("branding"); + if (!brandingOptions.SplashscreenEnabled) + { + return NotFound(); + } + + string splashscreenPath; + + 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(); } - - return await GetImageInternal( - itemId, - imageType, - imageIndex, - tag, - format, - maxWidth, - maxHeight, - percentPlayed, - unplayedCount, - width, - height, - quality, - fillWidth, - fillHeight, - blur, - backgroundColor, - foregroundLayer, - item) - .ConfigureAwait(false); } - /// - /// Gets the item's image. - /// - /// Item id. - /// Image type. - /// Image index. - /// The maximum image width to return. - /// The maximum image height to return. - /// The fixed image width to return. - /// The fixed image height to return. - /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases. - /// Width of box to fill. - /// Height of box to fill. - /// Optional. Supply the cache tag from the item object to receive strong caching headers. - /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art. - /// Optional. The of the returned image. - /// Optional. Percent to render for the percent played overlay. - /// Optional. Unplayed count overlay to render. - /// Optional. Blur image. - /// Optional. Apply a background color for transparent images. - /// Optional. Apply a foreground layer on top of the image. - /// Image stream returned. - /// Item not found. - /// - /// A containing the file stream on success, - /// or a if item not found. - /// - [HttpGet("Items/{itemId}/Images/{imageType}/{imageIndex}")] - [HttpHead("Items/{itemId}/Images/{imageType}/{imageIndex}", Name = "HeadItemImageByIndex")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesImageFile] - public async Task 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(); - } + var outputFormats = GetOutputFormats(format); - return await GetImageInternal( - itemId, - imageType, - imageIndex, - tag, - format, - maxWidth, - maxHeight, - percentPlayed, - unplayedCount, - width, - height, - quality, - fillWidth, - fillHeight, - blur, - backgroundColor, - foregroundLayer, - item) - .ConfigureAwait(false); + TimeSpan? cacheDuration = null; + if (!string.IsNullOrEmpty(tag)) + { + cacheDuration = TimeSpan.FromDays(365); } - /// - /// Gets the item's image. - /// - /// Item id. - /// Image type. - /// The maximum image width to return. - /// The maximum image height to return. - /// The fixed image width to return. - /// The fixed image height to return. - /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases. - /// Width of box to fill. - /// Height of box to fill. - /// Optional. Supply the cache tag from the item object to receive strong caching headers. - /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art. - /// Determines the output format of the image - original,gif,jpg,png. - /// Optional. Percent to render for the percent played overlay. - /// Optional. Unplayed count overlay to render. - /// Optional. Blur image. - /// Optional. Apply a background color for transparent images. - /// Optional. Apply a foreground layer on top of the image. - /// Image index. - /// Image stream returned. - /// Item not found. - /// - /// A containing the file stream on success, - /// or a if item not found. - /// - [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 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 options = new ImageProcessingOptions { - var item = _libraryManager.GetItemById(itemId); - if (item is null) + Image = new ItemImageInfo { - return NotFound(); - } + 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 GetImageInternal( - itemId, - imageType, - imageIndex, - tag, - format, - maxWidth, - maxHeight, - percentPlayed, - unplayedCount, - width, - height, - quality, - fillWidth, - fillHeight, - blur, - backgroundColor, - foregroundLayer, - item) - .ConfigureAwait(false); + return await GetImageResult( + options, + cacheDuration, + ImmutableDictionary.Empty) + .ConfigureAwait(false); + } + + /// + /// Uploads a custom splashscreen. + /// The body is expected to the image contents base64 encoded. + /// + /// A indicating success. + /// Successfully uploaded new splashscreen. + /// Error reading MimeType from uploaded image. + /// User does not have permission to upload splashscreen.. + /// Error reading the image format. + [HttpPost("Branding/Splashscreen")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [AcceptsImageFile] + public async Task UploadCustomSplashscreen() + { + if (!TryGetImageExtensionFromContentType(Request.ContentType, out var extension)) + { + return BadRequest("Incorrect ContentType."); } - /// - /// Get artist image by name. - /// - /// Artist name. - /// Image type. - /// Optional. Supply the cache tag from the item object to receive strong caching headers. - /// Determines the output format of the image - original,gif,jpg,png. - /// The maximum image width to return. - /// The maximum image height to return. - /// Optional. Percent to render for the percent played overlay. - /// Optional. Unplayed count overlay to render. - /// The fixed image width to return. - /// The fixed image height to return. - /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases. - /// Width of box to fill. - /// Height of box to fill. - /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art. - /// Optional. Blur image. - /// Optional. Apply a background color for transparent images. - /// Optional. Apply a foreground layer on top of the image. - /// Image index. - /// Image stream returned. - /// Item not found. - /// - /// A containing the file stream on success, - /// or a if item not found. - /// - [HttpGet("Artists/{name}/Images/{imageType}/{imageIndex}")] - [HttpHead("Artists/{name}/Images/{imageType}/{imageIndex}", Name = "HeadArtistImage")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesImageFile] - public async Task GetArtistImage( - [FromRoute, Required] string name, - [FromRoute, Required] ImageType imageType, - [FromQuery] string? tag, - [FromQuery] ImageFormat? format, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] double? percentPlayed, - [FromQuery] int? unplayedCount, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? quality, - [FromQuery] int? fillWidth, - [FromQuery] int? fillHeight, - [FromQuery, ParameterObsolete] bool? cropWhitespace, - [FromQuery] int? blur, - [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer, - [FromRoute, Required] int imageIndex) - { - var item = _libraryManager.GetArtist(name); - if (item is null) - { - return NotFound(); - } - - return await GetImageInternal( - item.Id, - imageType, - imageIndex, - tag, - format, - maxWidth, - maxHeight, - percentPlayed, - unplayedCount, - width, - height, - quality, - fillWidth, - fillHeight, - blur, - backgroundColor, - foregroundLayer, - item) - .ConfigureAwait(false); - } - - /// - /// Get genre image by name. - /// - /// Genre name. - /// Image type. - /// Optional. Supply the cache tag from the item object to receive strong caching headers. - /// Determines the output format of the image - original,gif,jpg,png. - /// The maximum image width to return. - /// The maximum image height to return. - /// Optional. Percent to render for the percent played overlay. - /// Optional. Unplayed count overlay to render. - /// The fixed image width to return. - /// The fixed image height to return. - /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases. - /// Width of box to fill. - /// Height of box to fill. - /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art. - /// Optional. Blur image. - /// Optional. Apply a background color for transparent images. - /// Optional. Apply a foreground layer on top of the image. - /// Image index. - /// Image stream returned. - /// Item not found. - /// - /// A containing the file stream on success, - /// or a if item not found. - /// - [HttpGet("Genres/{name}/Images/{imageType}")] - [HttpHead("Genres/{name}/Images/{imageType}", Name = "HeadGenreImage")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesImageFile] - public async Task GetGenreImage( - [FromRoute, Required] string name, - [FromRoute, Required] ImageType imageType, - [FromQuery] string? tag, - [FromQuery] ImageFormat? format, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] double? percentPlayed, - [FromQuery] int? unplayedCount, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? quality, - [FromQuery] int? fillWidth, - [FromQuery] int? fillHeight, - [FromQuery, ParameterObsolete] bool? cropWhitespace, - [FromQuery] int? blur, - [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer, - [FromQuery] int? imageIndex) - { - var item = _libraryManager.GetGenre(name); - if (item is null) - { - return NotFound(); - } - - return await GetImageInternal( - item.Id, - imageType, - imageIndex, - tag, - format, - maxWidth, - maxHeight, - percentPlayed, - unplayedCount, - width, - height, - quality, - fillWidth, - fillHeight, - blur, - backgroundColor, - foregroundLayer, - item) - .ConfigureAwait(false); - } - - /// - /// Get genre image by name. - /// - /// Genre name. - /// Image type. - /// Image index. - /// Optional. Supply the cache tag from the item object to receive strong caching headers. - /// Determines the output format of the image - original,gif,jpg,png. - /// The maximum image width to return. - /// The maximum image height to return. - /// Optional. Percent to render for the percent played overlay. - /// Optional. Unplayed count overlay to render. - /// The fixed image width to return. - /// The fixed image height to return. - /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases. - /// Width of box to fill. - /// Height of box to fill. - /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art. - /// Optional. Blur image. - /// Optional. Apply a background color for transparent images. - /// Optional. Apply a foreground layer on top of the image. - /// Image stream returned. - /// Item not found. - /// - /// A containing the file stream on success, - /// or a if item not found. - /// - [HttpGet("Genres/{name}/Images/{imageType}/{imageIndex}")] - [HttpHead("Genres/{name}/Images/{imageType}/{imageIndex}", Name = "HeadGenreImageByIndex")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesImageFile] - public async Task GetGenreImageByIndex( - [FromRoute, Required] string name, - [FromRoute, Required] ImageType imageType, - [FromRoute, Required] int imageIndex, - [FromQuery] string? tag, - [FromQuery] ImageFormat? format, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] double? percentPlayed, - [FromQuery] int? unplayedCount, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? quality, - [FromQuery] int? fillWidth, - [FromQuery] int? fillHeight, - [FromQuery, ParameterObsolete] bool? cropWhitespace, - [FromQuery] int? blur, - [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer) - { - var item = _libraryManager.GetGenre(name); - if (item is null) - { - return NotFound(); - } - - return await GetImageInternal( - item.Id, - imageType, - imageIndex, - tag, - format, - maxWidth, - maxHeight, - percentPlayed, - unplayedCount, - width, - height, - quality, - fillWidth, - fillHeight, - blur, - backgroundColor, - foregroundLayer, - item) - .ConfigureAwait(false); - } - - /// - /// Get music genre image by name. - /// - /// Music genre name. - /// Image type. - /// Optional. Supply the cache tag from the item object to receive strong caching headers. - /// Determines the output format of the image - original,gif,jpg,png. - /// The maximum image width to return. - /// The maximum image height to return. - /// Optional. Percent to render for the percent played overlay. - /// Optional. Unplayed count overlay to render. - /// The fixed image width to return. - /// The fixed image height to return. - /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases. - /// Width of box to fill. - /// Height of box to fill. - /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art. - /// Optional. Blur image. - /// Optional. Apply a background color for transparent images. - /// Optional. Apply a foreground layer on top of the image. - /// Image index. - /// Image stream returned. - /// Item not found. - /// - /// A containing the file stream on success, - /// or a if item not found. - /// - [HttpGet("MusicGenres/{name}/Images/{imageType}")] - [HttpHead("MusicGenres/{name}/Images/{imageType}", Name = "HeadMusicGenreImage")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesImageFile] - public async Task GetMusicGenreImage( - [FromRoute, Required] string name, - [FromRoute, Required] ImageType imageType, - [FromQuery] string? tag, - [FromQuery] ImageFormat? format, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] double? percentPlayed, - [FromQuery] int? unplayedCount, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? quality, - [FromQuery] int? fillWidth, - [FromQuery] int? fillHeight, - [FromQuery, ParameterObsolete] bool? cropWhitespace, - [FromQuery] int? blur, - [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer, - [FromQuery] int? imageIndex) - { - var item = _libraryManager.GetMusicGenre(name); - if (item is null) - { - return NotFound(); - } - - return await GetImageInternal( - item.Id, - imageType, - imageIndex, - tag, - format, - maxWidth, - maxHeight, - percentPlayed, - unplayedCount, - width, - height, - quality, - fillWidth, - fillHeight, - blur, - backgroundColor, - foregroundLayer, - item) - .ConfigureAwait(false); - } - - /// - /// Get music genre image by name. - /// - /// Music genre name. - /// Image type. - /// Image index. - /// Optional. Supply the cache tag from the item object to receive strong caching headers. - /// Determines the output format of the image - original,gif,jpg,png. - /// The maximum image width to return. - /// The maximum image height to return. - /// Optional. Percent to render for the percent played overlay. - /// Optional. Unplayed count overlay to render. - /// The fixed image width to return. - /// The fixed image height to return. - /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases. - /// Width of box to fill. - /// Height of box to fill. - /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art. - /// Optional. Blur image. - /// Optional. Apply a background color for transparent images. - /// Optional. Apply a foreground layer on top of the image. - /// Image stream returned. - /// Item not found. - /// - /// A containing the file stream on success, - /// or a if item not found. - /// - [HttpGet("MusicGenres/{name}/Images/{imageType}/{imageIndex}")] - [HttpHead("MusicGenres/{name}/Images/{imageType}/{imageIndex}", Name = "HeadMusicGenreImageByIndex")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesImageFile] - public async Task GetMusicGenreImageByIndex( - [FromRoute, Required] string name, - [FromRoute, Required] ImageType imageType, - [FromRoute, Required] int imageIndex, - [FromQuery] string? tag, - [FromQuery] ImageFormat? format, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] double? percentPlayed, - [FromQuery] int? unplayedCount, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? quality, - [FromQuery] int? fillWidth, - [FromQuery] int? fillHeight, - [FromQuery, ParameterObsolete] bool? cropWhitespace, - [FromQuery] int? blur, - [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer) - { - var item = _libraryManager.GetMusicGenre(name); - if (item is null) - { - return NotFound(); - } - - return await GetImageInternal( - item.Id, - imageType, - imageIndex, - tag, - format, - maxWidth, - maxHeight, - percentPlayed, - unplayedCount, - width, - height, - quality, - fillWidth, - fillHeight, - blur, - backgroundColor, - foregroundLayer, - item) - .ConfigureAwait(false); - } - - /// - /// Get person image by name. - /// - /// Person name. - /// Image type. - /// Optional. Supply the cache tag from the item object to receive strong caching headers. - /// Determines the output format of the image - original,gif,jpg,png. - /// The maximum image width to return. - /// The maximum image height to return. - /// Optional. Percent to render for the percent played overlay. - /// Optional. Unplayed count overlay to render. - /// The fixed image width to return. - /// The fixed image height to return. - /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases. - /// Width of box to fill. - /// Height of box to fill. - /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art. - /// Optional. Blur image. - /// Optional. Apply a background color for transparent images. - /// Optional. Apply a foreground layer on top of the image. - /// Image index. - /// Image stream returned. - /// Item not found. - /// - /// A containing the file stream on success, - /// or a if item not found. - /// - [HttpGet("Persons/{name}/Images/{imageType}")] - [HttpHead("Persons/{name}/Images/{imageType}", Name = "HeadPersonImage")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesImageFile] - public async Task GetPersonImage( - [FromRoute, Required] string name, - [FromRoute, Required] ImageType imageType, - [FromQuery] string? tag, - [FromQuery] ImageFormat? format, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] double? percentPlayed, - [FromQuery] int? unplayedCount, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? quality, - [FromQuery] int? fillWidth, - [FromQuery] int? fillHeight, - [FromQuery, ParameterObsolete] bool? cropWhitespace, - [FromQuery] int? blur, - [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer, - [FromQuery] int? imageIndex) - { - var item = _libraryManager.GetPerson(name); - if (item is null) - { - return NotFound(); - } - - return await GetImageInternal( - item.Id, - imageType, - imageIndex, - tag, - format, - maxWidth, - maxHeight, - percentPlayed, - unplayedCount, - width, - height, - quality, - fillWidth, - fillHeight, - blur, - backgroundColor, - foregroundLayer, - item) - .ConfigureAwait(false); - } - - /// - /// Get person image by name. - /// - /// Person name. - /// Image type. - /// Image index. - /// Optional. Supply the cache tag from the item object to receive strong caching headers. - /// Determines the output format of the image - original,gif,jpg,png. - /// The maximum image width to return. - /// The maximum image height to return. - /// Optional. Percent to render for the percent played overlay. - /// Optional. Unplayed count overlay to render. - /// The fixed image width to return. - /// The fixed image height to return. - /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases. - /// Width of box to fill. - /// Height of box to fill. - /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art. - /// Optional. Blur image. - /// Optional. Apply a background color for transparent images. - /// Optional. Apply a foreground layer on top of the image. - /// Image stream returned. - /// Item not found. - /// - /// A containing the file stream on success, - /// or a if item not found. - /// - [HttpGet("Persons/{name}/Images/{imageType}/{imageIndex}")] - [HttpHead("Persons/{name}/Images/{imageType}/{imageIndex}", Name = "HeadPersonImageByIndex")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesImageFile] - public async Task GetPersonImageByIndex( - [FromRoute, Required] string name, - [FromRoute, Required] ImageType imageType, - [FromRoute, Required] int imageIndex, - [FromQuery] string? tag, - [FromQuery] ImageFormat? format, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] double? percentPlayed, - [FromQuery] int? unplayedCount, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? quality, - [FromQuery] int? fillWidth, - [FromQuery] int? fillHeight, - [FromQuery, ParameterObsolete] bool? cropWhitespace, - [FromQuery] int? blur, - [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer) - { - var item = _libraryManager.GetPerson(name); - if (item is null) - { - return NotFound(); - } - - return await GetImageInternal( - item.Id, - imageType, - imageIndex, - tag, - format, - maxWidth, - maxHeight, - percentPlayed, - unplayedCount, - width, - height, - quality, - fillWidth, - fillHeight, - blur, - backgroundColor, - foregroundLayer, - item) - .ConfigureAwait(false); - } - - /// - /// Get studio image by name. - /// - /// Studio name. - /// Image type. - /// Optional. Supply the cache tag from the item object to receive strong caching headers. - /// Determines the output format of the image - original,gif,jpg,png. - /// The maximum image width to return. - /// The maximum image height to return. - /// Optional. Percent to render for the percent played overlay. - /// Optional. Unplayed count overlay to render. - /// The fixed image width to return. - /// The fixed image height to return. - /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases. - /// Width of box to fill. - /// Height of box to fill. - /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art. - /// Optional. Blur image. - /// Optional. Apply a background color for transparent images. - /// Optional. Apply a foreground layer on top of the image. - /// Image index. - /// Image stream returned. - /// Item not found. - /// - /// A containing the file stream on success, - /// or a if item not found. - /// - [HttpGet("Studios/{name}/Images/{imageType}")] - [HttpHead("Studios/{name}/Images/{imageType}", Name = "HeadStudioImage")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesImageFile] - public async Task GetStudioImage( - [FromRoute, Required] string name, - [FromRoute, Required] ImageType imageType, - [FromQuery] string? tag, - [FromQuery] ImageFormat? format, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] double? percentPlayed, - [FromQuery] int? unplayedCount, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? quality, - [FromQuery] int? fillWidth, - [FromQuery] int? fillHeight, - [FromQuery, ParameterObsolete] bool? cropWhitespace, - [FromQuery] int? blur, - [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer, - [FromQuery] int? imageIndex) - { - var item = _libraryManager.GetStudio(name); - if (item is null) - { - return NotFound(); - } - - return await GetImageInternal( - item.Id, - imageType, - imageIndex, - tag, - format, - maxWidth, - maxHeight, - percentPlayed, - unplayedCount, - width, - height, - quality, - fillWidth, - fillHeight, - blur, - backgroundColor, - foregroundLayer, - item) - .ConfigureAwait(false); - } - - /// - /// Get studio image by name. - /// - /// Studio name. - /// Image type. - /// Image index. - /// Optional. Supply the cache tag from the item object to receive strong caching headers. - /// Determines the output format of the image - original,gif,jpg,png. - /// The maximum image width to return. - /// The maximum image height to return. - /// Optional. Percent to render for the percent played overlay. - /// Optional. Unplayed count overlay to render. - /// The fixed image width to return. - /// The fixed image height to return. - /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases. - /// Width of box to fill. - /// Height of box to fill. - /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art. - /// Optional. Blur image. - /// Optional. Apply a background color for transparent images. - /// Optional. Apply a foreground layer on top of the image. - /// Image stream returned. - /// Item not found. - /// - /// A containing the file stream on success, - /// or a if item not found. - /// - [HttpGet("Studios/{name}/Images/{imageType}/{imageIndex}")] - [HttpHead("Studios/{name}/Images/{imageType}/{imageIndex}", Name = "HeadStudioImageByIndex")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesImageFile] - public async Task GetStudioImageByIndex( - [FromRoute, Required] string name, - [FromRoute, Required] ImageType imageType, - [FromRoute, Required] int imageIndex, - [FromQuery] string? tag, - [FromQuery] ImageFormat? format, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] double? percentPlayed, - [FromQuery] int? unplayedCount, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? quality, - [FromQuery] int? fillWidth, - [FromQuery] int? fillHeight, - [FromQuery, ParameterObsolete] bool? cropWhitespace, - [FromQuery] int? blur, - [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer) - { - var item = _libraryManager.GetStudio(name); - if (item is null) - { - return NotFound(); - } - - return await GetImageInternal( - item.Id, - imageType, - imageIndex, - tag, - format, - maxWidth, - maxHeight, - percentPlayed, - unplayedCount, - width, - height, - quality, - fillWidth, - fillHeight, - blur, - backgroundColor, - foregroundLayer, - item) - .ConfigureAwait(false); - } - - /// - /// Get user profile image. - /// - /// User id. - /// Image type. - /// Optional. Supply the cache tag from the item object to receive strong caching headers. - /// Determines the output format of the image - original,gif,jpg,png. - /// The maximum image width to return. - /// The maximum image height to return. - /// Optional. Percent to render for the percent played overlay. - /// Optional. Unplayed count overlay to render. - /// The fixed image width to return. - /// The fixed image height to return. - /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases. - /// Width of box to fill. - /// Height of box to fill. - /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art. - /// Optional. Blur image. - /// Optional. Apply a background color for transparent images. - /// Optional. Apply a foreground layer on top of the image. - /// Image index. - /// Image stream returned. - /// Item not found. - /// - /// A containing the file stream on success, - /// or a if item not found. - /// - [HttpGet("Users/{userId}/Images/{imageType}")] - [HttpHead("Users/{userId}/Images/{imageType}", Name = "HeadUserImage")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesImageFile] - public async Task GetUserImage( - [FromRoute, Required] Guid userId, - [FromRoute, Required] ImageType imageType, - [FromQuery] string? tag, - [FromQuery] ImageFormat? format, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] double? percentPlayed, - [FromQuery] int? unplayedCount, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? quality, - [FromQuery] int? fillWidth, - [FromQuery] int? fillHeight, - [FromQuery, ParameterObsolete] bool? cropWhitespace, - [FromQuery] int? blur, - [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer, - [FromQuery] int? imageIndex) - { - var user = _userManager.GetUserById(userId); - if (user?.ProfileImage is null) - { - return NotFound(); - } - - var info = new ItemImageInfo - { - Path = user.ProfileImage.Path, - Type = ImageType.Profile, - DateModified = user.ProfileImage.LastModified - }; - - if (width.HasValue) - { - info.Width = width.Value; - } - - if (height.HasValue) - { - info.Height = height.Value; - } - - 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); - } - - /// - /// Get user profile image. - /// - /// User id. - /// Image type. - /// Image index. - /// Optional. Supply the cache tag from the item object to receive strong caching headers. - /// Determines the output format of the image - original,gif,jpg,png. - /// The maximum image width to return. - /// The maximum image height to return. - /// Optional. Percent to render for the percent played overlay. - /// Optional. Unplayed count overlay to render. - /// The fixed image width to return. - /// The fixed image height to return. - /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases. - /// Width of box to fill. - /// Height of box to fill. - /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art. - /// Optional. Blur image. - /// Optional. Apply a background color for transparent images. - /// Optional. Apply a foreground layer on top of the image. - /// Image stream returned. - /// Item not found. - /// - /// A containing the file stream on success, - /// or a if item not found. - /// - [HttpGet("Users/{userId}/Images/{imageType}/{imageIndex}")] - [HttpHead("Users/{userId}/Images/{imageType}/{imageIndex}", Name = "HeadUserImageByIndex")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesImageFile] - public async Task 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(); - } - - var info = new ItemImageInfo - { - Path = user.ProfileImage.Path, - Type = ImageType.Profile, - DateModified = user.ProfileImage.LastModified - }; - - if (width.HasValue) - { - info.Width = width.Value; - } - - if (height.HasValue) - { - info.Height = height.Value; - } - - 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); - } - - /// - /// Generates or gets the splashscreen. - /// - /// Supply the cache tag from the item object to receive strong caching headers. - /// Determines the output format of the image - original,gif,jpg,png. - /// The maximum image width to return. - /// The maximum image height to return. - /// The fixed image width to return. - /// The fixed image height to return. - /// Width of box to fill. - /// Height of box to fill. - /// Blur image. - /// Apply a background color for transparent images. - /// Apply a foreground layer on top of the image. - /// Quality setting, from 0-100. - /// Splashscreen returned successfully. - /// The splashscreen. - [HttpGet("Branding/Splashscreen")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesImageFile] - public async Task 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 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("branding"); - if (!brandingOptions.SplashscreenEnabled) + 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)) { - return NotFound(); - } - - string splashscreenPath; - - 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(); - } - } - - var outputFormats = GetOutputFormats(format); - - TimeSpan? cacheDuration = null; - if (!string.IsNullOrEmpty(tag)) - { - cacheDuration = TimeSpan.FromDays(365); - } - - 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.Empty) - .ConfigureAwait(false); - } - - /// - /// Uploads a custom splashscreen. - /// The body is expected to the image contents base64 encoded. - /// - /// A indicating success. - /// Successfully uploaded new splashscreen. - /// Error reading MimeType from uploaded image. - /// User does not have permission to upload splashscreen.. - /// Error reading the image format. - [HttpPost("Branding/Splashscreen")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [AcceptsImageFile] - public async Task 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 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("branding"); - 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)) - { - await memoryStream.CopyToAsync(fs, CancellationToken.None).ConfigureAwait(false); - } - - return NoContent(); - } - } - - /// - /// Delete a custom splashscreen. - /// - /// A indicating success. - /// Successfully deleted the custom splashscreen. - /// User does not have permission to delete splashscreen.. - [HttpDelete("Branding/Splashscreen")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult DeleteCustomSplashscreen() - { - var brandingOptions = _serverConfigurationManager.GetConfiguration("branding"); - if (!string.IsNullOrEmpty(brandingOptions.SplashscreenLocation) - && System.IO.File.Exists(brandingOptions.SplashscreenLocation)) - { - 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 GetMemoryStream(Stream inputStream) + /// + /// Delete a custom splashscreen. + /// + /// A indicating success. + /// Successfully deleted the custom splashscreen. + /// User does not have permission to delete splashscreen.. + [HttpDelete("Branding/Splashscreen")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult DeleteCustomSplashscreen() + { + var brandingOptions = _serverConfigurationManager.GetConfiguration("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) + return NoContent(); + } + + private static async Task GetMemoryStream(Stream inputStream) + { + 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); + } + + private ImageInfo? GetImageInfo(BaseItem item, ItemImageInfo info, int? imageIndex) + { + int? width = null; + int? height = null; + string? blurhash = null; + long length = 0; + + try { - int? width = null; - int? height = null; - string? blurhash = null; - long length = 0; - - try + if (info.IsLocalFile) { - 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) { - 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; - } + width = null; + height = null; } } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting image information for {Item}", item.Name); - } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting image information for {Item}", item.Name); + } - try + try + { + return new ImageInfo { - return new ImageInfo - { - Path = info.Path, - ImageIndex = imageIndex, - ImageType = info.Type, - ImageTag = _imageProcessor.GetImageCacheTag(item, info), - Size = length, - BlurHash = blurhash, - Width = width, - Height = height - }; + 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; + } + } + + private async Task 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) + { + percentPlayed = null; } - catch (Exception ex) + else if (percentPlayed.Value >= 100) { - _logger.LogError(ex, "Error getting image information for {Path}", info.Path); - return null; + percentPlayed = null; } } - private async Task 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.HasValue) - { - if (percentPlayed.Value <= 0) - { - percentPlayed = null; - } - else if (percentPlayed.Value >= 100) - { - percentPlayed = null; - } - } + unplayedCount = null; + } - if (percentPlayed.HasValue) - { - unplayedCount = null; - } - - if (unplayedCount.HasValue - && unplayedCount.Value <= 0) - { - unplayedCount = null; - } + if (unplayedCount.HasValue + && unplayedCount.Value <= 0) + { + unplayedCount = null; + } + if (imageInfo is null) + { + imageInfo = item?.GetImageInfo(imageType, imageIndex ?? 0); if (imageInfo is null) { - 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)); - } + return NotFound(string.Format(NumberFormatInfo.InvariantInfo, "{0} does not have an image of type {1}", item?.Name, imageType)); } - - var outputFormats = GetOutputFormats(format); - - TimeSpan? cacheDuration = null; - - if (!string.IsNullOrEmpty(tag)) - { - cacheDuration = TimeSpan.FromDays(365); - } - - var responseHeaders = new Dictionary - { - { "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); - } - - var options = new ImageProcessingOptions - { - Height = height, - ImageIndex = imageIndex ?? 0, - Image = imageInfo, - Item = item, - ItemId = itemId, - MaxHeight = maxHeight, - MaxWidth = maxWidth, - FillHeight = fillHeight, - FillWidth = fillWidth, - Quality = quality ?? 100, - Width = width, - PercentPlayed = percentPlayed ?? 0, - UnplayedCount = unplayedCount, - Blur = blur, - BackgroundColor = backgroundColor, - ForegroundLayer = foregroundLayer, - SupportedOutputFormats = outputFormats - }; - - return await GetImageResult( - options, - cacheDuration, - responseHeaders).ConfigureAwait(false); } - private ImageFormat[] GetOutputFormats(ImageFormat? format) - { - if (format.HasValue) - { - return new[] { format.Value }; - } + var outputFormats = GetOutputFormats(format); - return GetClientSupportedFormats(); + TimeSpan? cacheDuration = null; + + if (!string.IsNullOrEmpty(tag)) + { + cacheDuration = TimeSpan.FromDays(365); } - private ImageFormat[] GetClientSupportedFormats() + var responseHeaders = new Dictionary { - 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) - { - supportedFormats[i] = type.Substring(0, index); - } - } + { "transferMode.dlna.org", "Interactive" }, + { "realTimeInfo.dlna.org", "DLNA.ORG_TLAG=*" } + }; - var acceptParam = Request.Query[HeaderNames.Accept]; - - var supportsWebP = SupportsFormat(supportedFormats, acceptParam, ImageFormat.Webp, false); - - if (!supportsWebP) - { - var userAgent = Request.Headers[HeaderNames.UserAgent].ToString(); - if (userAgent.Contains("crosswalk", StringComparison.OrdinalIgnoreCase) - && userAgent.Contains("android", StringComparison.OrdinalIgnoreCase)) - { - supportsWebP = true; - } - } - - var formats = new List(4); - - if (supportsWebP) - { - formats.Add(ImageFormat.Webp); - } - - formats.Add(ImageFormat.Jpg); - formats.Add(ImageFormat.Png); - - if (SupportsFormat(supportedFormats, acceptParam, ImageFormat.Gif, true)) - { - formats.Add(ImageFormat.Gif); - } - - return formats.ToArray(); + if (!imageInfo.IsLocalFile && item is not null) + { + imageInfo = await _libraryManager.ConvertImageToLocal(item, imageInfo, imageIndex ?? 0).ConfigureAwait(false); } - private bool SupportsFormat(IReadOnlyCollection requestAcceptTypes, string? acceptParam, ImageFormat format, bool acceptAll) + var options = new ImageProcessingOptions { - if (requestAcceptTypes.Contains(format.GetMimeType())) - { - return true; - } + Height = height, + ImageIndex = imageIndex ?? 0, + Image = imageInfo, + Item = item, + ItemId = itemId, + MaxHeight = maxHeight, + MaxWidth = maxWidth, + FillHeight = fillHeight, + FillWidth = fillWidth, + Quality = quality ?? 100, + Width = width, + PercentPlayed = percentPlayed ?? 0, + UnplayedCount = unplayedCount, + Blur = blur, + BackgroundColor = backgroundColor, + ForegroundLayer = foregroundLayer, + SupportedOutputFormats = outputFormats + }; - if (acceptAll && requestAcceptTypes.Contains("*/*")) - { - return true; - } + return await GetImageResult( + options, + cacheDuration, + responseHeaders).ConfigureAwait(false); + } - // 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 ImageFormat[] GetOutputFormats(ImageFormat? format) + { + if (format.HasValue) + { + return new[] { format.Value }; } - private async Task GetImageResult( - ImageProcessingOptions imageProcessingOptions, - TimeSpan? cacheDuration, - IDictionary headers) + return GetClientSupportedFormats(); + } + + private ImageFormat[] GetClientSupportedFormats() + { + var supportedFormats = Request.Headers.GetCommaSeparatedValues(HeaderNames.Accept); + for (var i = 0; i < supportedFormats.Length; i++) { - 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) + // Remove charsets etc. (anything after semi-colon) + var type = supportedFormats[i]; + int index = type.IndexOf(';', StringComparison.Ordinal); + if (index != -1) { - // disableCaching = true; + supportedFormats[i] = type.Substring(0, index); } + } - foreach (var (key, value) in headers) + var acceptParam = Request.Query[HeaderNames.Accept]; + + var supportsWebP = SupportsFormat(supportedFormats, acceptParam, ImageFormat.Webp, false); + + if (!supportsWebP) + { + var userAgent = Request.Headers[HeaderNames.UserAgent].ToString(); + if (userAgent.Contains("crosswalk", StringComparison.OrdinalIgnoreCase) + && userAgent.Contains("android", StringComparison.OrdinalIgnoreCase)) { - Response.Headers.Add(key, value); + supportsWebP = true; } + } - 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); + var formats = new List(4); - if (disableCaching) + if (supportsWebP) + { + formats.Add(ImageFormat.Webp); + } + + formats.Add(ImageFormat.Jpg); + formats.Add(ImageFormat.Png); + + if (SupportsFormat(supportedFormats, acceptParam, ImageFormat.Gif, true)) + { + formats.Add(ImageFormat.Gif); + } + + return formats.ToArray(); + } + + private bool SupportsFormat(IReadOnlyCollection requestAcceptTypes, string? acceptParam, ImageFormat format, bool acceptAll) + { + if (requestAcceptTypes.Contains(format.GetMimeType())) + { + 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); + } + + private async Task GetImageResult( + ImageProcessingOptions imageProcessingOptions, + TimeSpan? cacheDuration, + IDictionary 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; + } + + foreach (var (key, value) in headers) + { + Response.Headers.Add(key, value); + } + + 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 (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) { - Response.Headers.Add(HeaderNames.CacheControl, "no-cache, no-store, must-revalidate"); - Response.Headers.Add(HeaderNames.Pragma, "no-cache, no-store, must-revalidate"); + Response.Headers.Add(HeaderNames.CacheControl, "public, max-age=" + cacheDuration.Value.TotalSeconds); } else { - if (cacheDuration.HasValue) - { - Response.Headers.Add(HeaderNames.CacheControl, "public, max-age=" + cacheDuration.Value.TotalSeconds); - } - else - { - Response.Headers.Add(HeaderNames.CacheControl, "public"); - } - - Response.Headers.Add(HeaderNames.LastModified, dateImageModified.ToUniversalTime().ToString("ddd, dd MMM yyyy HH:mm:ss \"GMT\"", CultureInfo.InvariantCulture)); - - // 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(); - } - } + Response.Headers.Add(HeaderNames.CacheControl, "public"); } - return PhysicalFile(imagePath, imageContentType ?? MediaTypeNames.Text.Plain); + Response.Headers.Add(HeaderNames.LastModified, dateImageModified.ToUniversalTime().ToString("ddd, dd MMM yyyy HH:mm:ss \"GMT\"", CultureInfo.InvariantCulture)); + + // 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(); + } + } } + + return PhysicalFile(imagePath, imageContentType ?? MediaTypeNames.Text.Plain); + } + + internal static bool TryGetImageExtensionFromContentType(string? contentType, [NotNullWhen(true)] out string? extension) + { + extension = null; + if (string.IsNullOrEmpty(contentType)) + { + return false; + } + + if (MediaTypeHeaderValue.TryParse(contentType, out var parsedValue) + && parsedValue.MediaType.HasValue + && MimeTypes.IsImage(parsedValue.MediaType.Value)) + { + extension = MimeTypes.ToExtension(parsedValue.MediaType.Value); + return extension is not null; + } + + return false; } } diff --git a/Jellyfin.Api/Controllers/InstantMixController.cs b/Jellyfin.Api/Controllers/InstantMixController.cs index 2e0d3cb99e..4dc2a4253d 100644 --- a/Jellyfin.Api/Controllers/InstantMixController.cs +++ b/Jellyfin.Api/Controllers/InstantMixController.cs @@ -1,8 +1,8 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Entities; using MediaBrowser.Controller.Dto; @@ -16,346 +16,352 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// The instant mix controller. +/// +[Route("")] +[Authorize] +public class InstantMixController : BaseJellyfinApiController { + private readonly IUserManager _userManager; + private readonly IDtoService _dtoService; + private readonly ILibraryManager _libraryManager; + private readonly IMusicManager _musicManager; + /// - /// The instant mix controller. + /// Initializes a new instance of the class. /// - [Route("")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class InstantMixController : BaseJellyfinApiController + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + 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; + _userManager = userManager; + _dtoService = dtoService; + _musicManager = musicManager; + _libraryManager = libraryManager; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - public InstantMixController( - IUserManager userManager, - IDtoService dtoService, - IMusicManager musicManager, - ILibraryManager libraryManager) + /// + /// Creates an instant playlist based on a given song. + /// + /// The item id. + /// Optional. Filter by user id, and attach user data. + /// Optional. The maximum number of records to return. + /// Optional. Specify additional fields of information to return in the output. + /// Optional. Include image information in output. + /// Optional. Include user data. + /// Optional. The max number of images to return, per image type. + /// Optional. The image types to include in the output. + /// Instant playlist returned. + /// A with the playlist items. + [HttpGet("Songs/{id}/InstantMix")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> 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); + } + + /// + /// Creates an instant playlist based on a given album. + /// + /// The item id. + /// Optional. Filter by user id, and attach user data. + /// Optional. The maximum number of records to return. + /// Optional. Specify additional fields of information to return in the output. + /// Optional. Include image information in output. + /// Optional. Include user data. + /// Optional. The max number of images to return, per image type. + /// Optional. The image types to include in the output. + /// Instant playlist returned. + /// A with the playlist items. + [HttpGet("Albums/{id}/InstantMix")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> 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); + } + + /// + /// Creates an instant playlist based on a given playlist. + /// + /// The item id. + /// Optional. Filter by user id, and attach user data. + /// Optional. The maximum number of records to return. + /// Optional. Specify additional fields of information to return in the output. + /// Optional. Include image information in output. + /// Optional. Include user data. + /// Optional. The max number of images to return, per image type. + /// Optional. The image types to include in the output. + /// Instant playlist returned. + /// A with the playlist items. + [HttpGet("Playlists/{id}/InstantMix")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> 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); + } + + /// + /// Creates an instant playlist based on a given genre. + /// + /// The genre name. + /// Optional. Filter by user id, and attach user data. + /// Optional. The maximum number of records to return. + /// Optional. Specify additional fields of information to return in the output. + /// Optional. Include image information in output. + /// Optional. Include user data. + /// Optional. The max number of images to return, per image type. + /// Optional. The image types to include in the output. + /// Instant playlist returned. + /// A with the playlist items. + [HttpGet("MusicGenres/{name}/InstantMix")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> 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); + } + + /// + /// Creates an instant playlist based on a given artist. + /// + /// The item id. + /// Optional. Filter by user id, and attach user data. + /// Optional. The maximum number of records to return. + /// Optional. Specify additional fields of information to return in the output. + /// Optional. Include image information in output. + /// Optional. Include user data. + /// Optional. The max number of images to return, per image type. + /// Optional. The image types to include in the output. + /// Instant playlist returned. + /// A with the playlist items. + [HttpGet("Artists/{id}/InstantMix")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> 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); + } + + /// + /// Creates an instant playlist based on a given item. + /// + /// The item id. + /// Optional. Filter by user id, and attach user data. + /// Optional. The maximum number of records to return. + /// Optional. Specify additional fields of information to return in the output. + /// Optional. Include image information in output. + /// Optional. Include user data. + /// Optional. The max number of images to return, per image type. + /// Optional. The image types to include in the output. + /// Instant playlist returned. + /// A with the playlist items. + [HttpGet("Items/{id}/InstantMix")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> 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); + } + + /// + /// Creates an instant playlist based on a given artist. + /// + /// The item id. + /// Optional. Filter by user id, and attach user data. + /// Optional. The maximum number of records to return. + /// Optional. Specify additional fields of information to return in the output. + /// Optional. Include image information in output. + /// Optional. Include user data. + /// Optional. The max number of images to return, per image type. + /// Optional. The image types to include in the output. + /// Instant playlist returned. + /// A with the playlist items. + [HttpGet("Artists/InstantMix")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Obsolete("Use GetInstantMixFromArtists")] + public ActionResult> 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); + } + + /// + /// Creates an instant playlist based on a given genre. + /// + /// The item id. + /// Optional. Filter by user id, and attach user data. + /// Optional. The maximum number of records to return. + /// Optional. Specify additional fields of information to return in the output. + /// Optional. Include image information in output. + /// Optional. Include user data. + /// Optional. The max number of images to return, per image type. + /// Optional. The image types to include in the output. + /// Instant playlist returned. + /// A with the playlist items. + [HttpGet("MusicGenres/InstantMix")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> 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 GetResult(List items, User? user, int? limit, DtoOptions dtoOptions) + { + var list = items; + + var totalCount = list.Count; + + if (limit.HasValue && limit < list.Count) { - _userManager = userManager; - _dtoService = dtoService; - _musicManager = musicManager; - _libraryManager = libraryManager; + list = list.GetRange(0, limit.Value); } - /// - /// Creates an instant playlist based on a given song. - /// - /// The item id. - /// Optional. Filter by user id, and attach user data. - /// Optional. The maximum number of records to return. - /// Optional. Specify additional fields of information to return in the output. - /// Optional. Include image information in output. - /// Optional. Include user data. - /// Optional. The max number of images to return, per image type. - /// Optional. The image types to include in the output. - /// Instant playlist returned. - /// A with the playlist items. - [HttpGet("Songs/{id}/InstantMix")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> 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); - } + var returnList = _dtoService.GetBaseItemDtos(list, dtoOptions, user); - /// - /// Creates an instant playlist based on a given album. - /// - /// The item id. - /// Optional. Filter by user id, and attach user data. - /// Optional. The maximum number of records to return. - /// Optional. Specify additional fields of information to return in the output. - /// Optional. Include image information in output. - /// Optional. Include user data. - /// Optional. The max number of images to return, per image type. - /// Optional. The image types to include in the output. - /// Instant playlist returned. - /// A with the playlist items. - [HttpGet("Albums/{id}/InstantMix")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> 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); - } + var result = new QueryResult( + 0, + totalCount, + returnList); - /// - /// Creates an instant playlist based on a given playlist. - /// - /// The item id. - /// Optional. Filter by user id, and attach user data. - /// Optional. The maximum number of records to return. - /// Optional. Specify additional fields of information to return in the output. - /// Optional. Include image information in output. - /// Optional. Include user data. - /// Optional. The max number of images to return, per image type. - /// Optional. The image types to include in the output. - /// Instant playlist returned. - /// A with the playlist items. - [HttpGet("Playlists/{id}/InstantMix")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> 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); - } - - /// - /// Creates an instant playlist based on a given genre. - /// - /// The genre name. - /// Optional. Filter by user id, and attach user data. - /// Optional. The maximum number of records to return. - /// Optional. Specify additional fields of information to return in the output. - /// Optional. Include image information in output. - /// Optional. Include user data. - /// Optional. The max number of images to return, per image type. - /// Optional. The image types to include in the output. - /// Instant playlist returned. - /// A with the playlist items. - [HttpGet("MusicGenres/{name}/InstantMix")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> 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); - } - - /// - /// Creates an instant playlist based on a given artist. - /// - /// The item id. - /// Optional. Filter by user id, and attach user data. - /// Optional. The maximum number of records to return. - /// Optional. Specify additional fields of information to return in the output. - /// Optional. Include image information in output. - /// Optional. Include user data. - /// Optional. The max number of images to return, per image type. - /// Optional. The image types to include in the output. - /// Instant playlist returned. - /// A with the playlist items. - [HttpGet("Artists/{id}/InstantMix")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> 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); - } - - /// - /// Creates an instant playlist based on a given item. - /// - /// The item id. - /// Optional. Filter by user id, and attach user data. - /// Optional. The maximum number of records to return. - /// Optional. Specify additional fields of information to return in the output. - /// Optional. Include image information in output. - /// Optional. Include user data. - /// Optional. The max number of images to return, per image type. - /// Optional. The image types to include in the output. - /// Instant playlist returned. - /// A with the playlist items. - [HttpGet("Items/{id}/InstantMix")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> 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); - } - - /// - /// Creates an instant playlist based on a given artist. - /// - /// The item id. - /// Optional. Filter by user id, and attach user data. - /// Optional. The maximum number of records to return. - /// Optional. Specify additional fields of information to return in the output. - /// Optional. Include image information in output. - /// Optional. Include user data. - /// Optional. The max number of images to return, per image type. - /// Optional. The image types to include in the output. - /// Instant playlist returned. - /// A with the playlist items. - [HttpGet("Artists/InstantMix")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Obsolete("Use GetInstantMixFromArtists")] - public ActionResult> 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); - } - - /// - /// Creates an instant playlist based on a given genre. - /// - /// The item id. - /// Optional. Filter by user id, and attach user data. - /// Optional. The maximum number of records to return. - /// Optional. Specify additional fields of information to return in the output. - /// Optional. Include image information in output. - /// Optional. Include user data. - /// Optional. The max number of images to return, per image type. - /// Optional. The image types to include in the output. - /// Instant playlist returned. - /// A with the playlist items. - [HttpGet("MusicGenres/InstantMix")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> 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); - } - - private QueryResult GetResult(List items, User? user, int? limit, DtoOptions dtoOptions) - { - var list = items; - - var totalCount = list.Count; - - if (limit.HasValue && limit < list.Count) - { - list = list.GetRange(0, limit.Value); - } - - var returnList = _dtoService.GetBaseItemDtos(list, dtoOptions, user); - - var result = new QueryResult( - 0, - totalCount, - returnList); - - return result; - } + return result; } } diff --git a/Jellyfin.Api/Controllers/ItemLookupController.cs b/Jellyfin.Api/Controllers/ItemLookupController.cs index b6c5504db5..b030e74dda 100644 --- a/Jellyfin.Api/Controllers/ItemLookupController.cs +++ b/Jellyfin.Api/Controllers/ItemLookupController.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Constants; @@ -18,257 +17,256 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// Item lookup controller. +/// +[Route("")] +[Authorize] +public class ItemLookupController : BaseJellyfinApiController { + private readonly IProviderManager _providerManager; + private readonly IFileSystem _fileSystem; + private readonly ILibraryManager _libraryManager; + private readonly ILogger _logger; + /// - /// Item lookup controller. + /// Initializes a new instance of the class. /// - [Route("")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class ItemLookupController : BaseJellyfinApiController + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + public ItemLookupController( + IProviderManager providerManager, + IFileSystem fileSystem, + ILibraryManager libraryManager, + ILogger logger) { - private readonly IProviderManager _providerManager; - private readonly IFileSystem _fileSystem; - private readonly ILibraryManager _libraryManager; - private readonly ILogger _logger; + _providerManager = providerManager; + _fileSystem = fileSystem; + _libraryManager = libraryManager; + _logger = logger; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - public ItemLookupController( - IProviderManager providerManager, - IFileSystem fileSystem, - ILibraryManager libraryManager, - ILogger logger) + /// + /// Get the item's external id info. + /// + /// Item id. + /// External id info retrieved. + /// Item not found. + /// List of external id info. + [HttpGet("Items/{itemId}/ExternalIdInfos")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult> GetExternalIdInfos([FromRoute, Required] Guid itemId) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) { - _providerManager = providerManager; - _fileSystem = fileSystem; - _libraryManager = libraryManager; - _logger = logger; + return NotFound(); } - /// - /// Get the item's external id info. - /// - /// Item id. - /// External id info retrieved. - /// Item not found. - /// List of external id info. - [HttpGet("Items/{itemId}/ExternalIdInfos")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult> GetExternalIdInfos([FromRoute, Required] Guid itemId) - { - var item = _libraryManager.GetItemById(itemId); - if (item is null) + return Ok(_providerManager.GetExternalIdInfos(item)); + } + + /// + /// Get movie remote search. + /// + /// Remote search query. + /// Movie remote search executed. + /// + /// A that represents the asynchronous operation to get the remote search results. + /// The task result contains an containing the list of remote search results. + /// + [HttpPost("Items/RemoteSearch/Movie")] + public async Task>> GetMovieRemoteSearchResults([FromBody, Required] RemoteSearchQuery query) + { + var results = await _providerManager.GetRemoteSearchResults(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } + + /// + /// Get trailer remote search. + /// + /// Remote search query. + /// Trailer remote search executed. + /// + /// A that represents the asynchronous operation to get the remote search results. + /// The task result contains an containing the list of remote search results. + /// + [HttpPost("Items/RemoteSearch/Trailer")] + public async Task>> GetTrailerRemoteSearchResults([FromBody, Required] RemoteSearchQuery query) + { + var results = await _providerManager.GetRemoteSearchResults(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } + + /// + /// Get music video remote search. + /// + /// Remote search query. + /// Music video remote search executed. + /// + /// A that represents the asynchronous operation to get the remote search results. + /// The task result contains an containing the list of remote search results. + /// + [HttpPost("Items/RemoteSearch/MusicVideo")] + public async Task>> GetMusicVideoRemoteSearchResults([FromBody, Required] RemoteSearchQuery query) + { + var results = await _providerManager.GetRemoteSearchResults(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } + + /// + /// Get series remote search. + /// + /// Remote search query. + /// Series remote search executed. + /// + /// A that represents the asynchronous operation to get the remote search results. + /// The task result contains an containing the list of remote search results. + /// + [HttpPost("Items/RemoteSearch/Series")] + public async Task>> GetSeriesRemoteSearchResults([FromBody, Required] RemoteSearchQuery query) + { + var results = await _providerManager.GetRemoteSearchResults(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } + + /// + /// Get box set remote search. + /// + /// Remote search query. + /// Box set remote search executed. + /// + /// A that represents the asynchronous operation to get the remote search results. + /// The task result contains an containing the list of remote search results. + /// + [HttpPost("Items/RemoteSearch/BoxSet")] + public async Task>> GetBoxSetRemoteSearchResults([FromBody, Required] RemoteSearchQuery query) + { + var results = await _providerManager.GetRemoteSearchResults(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } + + /// + /// Get music artist remote search. + /// + /// Remote search query. + /// Music artist remote search executed. + /// + /// A that represents the asynchronous operation to get the remote search results. + /// The task result contains an containing the list of remote search results. + /// + [HttpPost("Items/RemoteSearch/MusicArtist")] + public async Task>> GetMusicArtistRemoteSearchResults([FromBody, Required] RemoteSearchQuery query) + { + var results = await _providerManager.GetRemoteSearchResults(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } + + /// + /// Get music album remote search. + /// + /// Remote search query. + /// Music album remote search executed. + /// + /// A that represents the asynchronous operation to get the remote search results. + /// The task result contains an containing the list of remote search results. + /// + [HttpPost("Items/RemoteSearch/MusicAlbum")] + public async Task>> GetMusicAlbumRemoteSearchResults([FromBody, Required] RemoteSearchQuery query) + { + var results = await _providerManager.GetRemoteSearchResults(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } + + /// + /// Get person remote search. + /// + /// Remote search query. + /// Person remote search executed. + /// + /// A that represents the asynchronous operation to get the remote search results. + /// The task result contains an containing the list of remote search results. + /// + [HttpPost("Items/RemoteSearch/Person")] + [Authorize(Policy = Policies.RequiresElevation)] + public async Task>> GetPersonRemoteSearchResults([FromBody, Required] RemoteSearchQuery query) + { + var results = await _providerManager.GetRemoteSearchResults(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } + + /// + /// Get book remote search. + /// + /// Remote search query. + /// Book remote search executed. + /// + /// A that represents the asynchronous operation to get the remote search results. + /// The task result contains an containing the list of remote search results. + /// + [HttpPost("Items/RemoteSearch/Book")] + public async Task>> GetBookRemoteSearchResults([FromBody, Required] RemoteSearchQuery query) + { + var results = await _providerManager.GetRemoteSearchResults(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } + + /// + /// Applies search criteria to an item and refreshes metadata. + /// + /// Item id. + /// The remote search result. + /// Optional. Whether or not to replace all images. Default: True. + /// Item metadata refreshed. + /// + /// A that represents the asynchronous operation to get the remote search results. + /// The task result contains an . + /// + [HttpPost("Items/RemoteSearch/Apply/{itemId}")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task 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)) { - return NotFound(); - } + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ImageRefreshMode = MetadataRefreshMode.FullRefresh, + ReplaceAllMetadata = true, + ReplaceAllImages = replaceAllImages, + SearchResult = searchResult, + RemoveOldMetadata = true + }, + CancellationToken.None).ConfigureAwait(false); - return Ok(_providerManager.GetExternalIdInfos(item)); - } - - /// - /// Get movie remote search. - /// - /// Remote search query. - /// Movie remote search executed. - /// - /// A that represents the asynchronous operation to get the remote search results. - /// The task result contains an containing the list of remote search results. - /// - [HttpPost("Items/RemoteSearch/Movie")] - public async Task>> GetMovieRemoteSearchResults([FromBody, Required] RemoteSearchQuery query) - { - var results = await _providerManager.GetRemoteSearchResults(query, CancellationToken.None) - .ConfigureAwait(false); - return Ok(results); - } - - /// - /// Get trailer remote search. - /// - /// Remote search query. - /// Trailer remote search executed. - /// - /// A that represents the asynchronous operation to get the remote search results. - /// The task result contains an containing the list of remote search results. - /// - [HttpPost("Items/RemoteSearch/Trailer")] - public async Task>> GetTrailerRemoteSearchResults([FromBody, Required] RemoteSearchQuery query) - { - var results = await _providerManager.GetRemoteSearchResults(query, CancellationToken.None) - .ConfigureAwait(false); - return Ok(results); - } - - /// - /// Get music video remote search. - /// - /// Remote search query. - /// Music video remote search executed. - /// - /// A that represents the asynchronous operation to get the remote search results. - /// The task result contains an containing the list of remote search results. - /// - [HttpPost("Items/RemoteSearch/MusicVideo")] - public async Task>> GetMusicVideoRemoteSearchResults([FromBody, Required] RemoteSearchQuery query) - { - var results = await _providerManager.GetRemoteSearchResults(query, CancellationToken.None) - .ConfigureAwait(false); - return Ok(results); - } - - /// - /// Get series remote search. - /// - /// Remote search query. - /// Series remote search executed. - /// - /// A that represents the asynchronous operation to get the remote search results. - /// The task result contains an containing the list of remote search results. - /// - [HttpPost("Items/RemoteSearch/Series")] - public async Task>> GetSeriesRemoteSearchResults([FromBody, Required] RemoteSearchQuery query) - { - var results = await _providerManager.GetRemoteSearchResults(query, CancellationToken.None) - .ConfigureAwait(false); - return Ok(results); - } - - /// - /// Get box set remote search. - /// - /// Remote search query. - /// Box set remote search executed. - /// - /// A that represents the asynchronous operation to get the remote search results. - /// The task result contains an containing the list of remote search results. - /// - [HttpPost("Items/RemoteSearch/BoxSet")] - public async Task>> GetBoxSetRemoteSearchResults([FromBody, Required] RemoteSearchQuery query) - { - var results = await _providerManager.GetRemoteSearchResults(query, CancellationToken.None) - .ConfigureAwait(false); - return Ok(results); - } - - /// - /// Get music artist remote search. - /// - /// Remote search query. - /// Music artist remote search executed. - /// - /// A that represents the asynchronous operation to get the remote search results. - /// The task result contains an containing the list of remote search results. - /// - [HttpPost("Items/RemoteSearch/MusicArtist")] - public async Task>> GetMusicArtistRemoteSearchResults([FromBody, Required] RemoteSearchQuery query) - { - var results = await _providerManager.GetRemoteSearchResults(query, CancellationToken.None) - .ConfigureAwait(false); - return Ok(results); - } - - /// - /// Get music album remote search. - /// - /// Remote search query. - /// Music album remote search executed. - /// - /// A that represents the asynchronous operation to get the remote search results. - /// The task result contains an containing the list of remote search results. - /// - [HttpPost("Items/RemoteSearch/MusicAlbum")] - public async Task>> GetMusicAlbumRemoteSearchResults([FromBody, Required] RemoteSearchQuery query) - { - var results = await _providerManager.GetRemoteSearchResults(query, CancellationToken.None) - .ConfigureAwait(false); - return Ok(results); - } - - /// - /// Get person remote search. - /// - /// Remote search query. - /// Person remote search executed. - /// - /// A that represents the asynchronous operation to get the remote search results. - /// The task result contains an containing the list of remote search results. - /// - [HttpPost("Items/RemoteSearch/Person")] - [Authorize(Policy = Policies.RequiresElevation)] - public async Task>> GetPersonRemoteSearchResults([FromBody, Required] RemoteSearchQuery query) - { - var results = await _providerManager.GetRemoteSearchResults(query, CancellationToken.None) - .ConfigureAwait(false); - return Ok(results); - } - - /// - /// Get book remote search. - /// - /// Remote search query. - /// Book remote search executed. - /// - /// A that represents the asynchronous operation to get the remote search results. - /// The task result contains an containing the list of remote search results. - /// - [HttpPost("Items/RemoteSearch/Book")] - public async Task>> GetBookRemoteSearchResults([FromBody, Required] RemoteSearchQuery query) - { - var results = await _providerManager.GetRemoteSearchResults(query, CancellationToken.None) - .ConfigureAwait(false); - return Ok(results); - } - - /// - /// Applies search criteria to an item and refreshes metadata. - /// - /// Item id. - /// The remote search result. - /// Optional. Whether or not to replace all images. Default: True. - /// Item metadata refreshed. - /// - /// A that represents the asynchronous operation to get the remote search results. - /// The task result contains an . - /// - [HttpPost("Items/RemoteSearch/Apply/{itemId}")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task 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); - - return NoContent(); - } + return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/ItemRefreshController.cs b/Jellyfin.Api/Controllers/ItemRefreshController.cs index 0dc3fbd05a..b8f6e91ad2 100644 --- a/Jellyfin.Api/Controllers/ItemRefreshController.cs +++ b/Jellyfin.Api/Controllers/ItemRefreshController.cs @@ -9,78 +9,77 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// Item Refresh Controller. +/// +[Route("Items")] +[Authorize(Policy = Policies.RequiresElevation)] +public class ItemRefreshController : BaseJellyfinApiController { + private readonly ILibraryManager _libraryManager; + private readonly IProviderManager _providerManager; + private readonly IFileSystem _fileSystem; + /// - /// Item Refresh Controller. + /// Initializes a new instance of the class. /// - [Route("Items")] - [Authorize(Policy = Policies.RequiresElevation)] - public class ItemRefreshController : BaseJellyfinApiController + /// Instance of interface. + /// Instance of interface. + /// Instance of interface. + 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; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of interface. - /// Instance of interface. - /// Instance of interface. - public ItemRefreshController( - ILibraryManager libraryManager, - IProviderManager providerManager, - IFileSystem fileSystem) + /// + /// Refreshes metadata for an item. + /// + /// Item id. + /// (Optional) Specifies the metadata refresh mode. + /// (Optional) Specifies the image refresh mode. + /// (Optional) Determines if metadata should be replaced. Only applicable if mode is FullRefresh. + /// (Optional) Determines if images should be replaced. Only applicable if mode is FullRefresh. + /// Item metadata refresh queued. + /// Item to refresh not found. + /// An on success, or a if the item could not be found. + [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(); } - /// - /// Refreshes metadata for an item. - /// - /// Item id. - /// (Optional) Specifies the metadata refresh mode. - /// (Optional) Specifies the image refresh mode. - /// (Optional) Determines if metadata should be replaced. Only applicable if mode is FullRefresh. - /// (Optional) Determines if images should be replaced. Only applicable if mode is FullRefresh. - /// Item metadata refresh queued. - /// Item to refresh not found. - /// An on success, or a if the item could not be found. - [HttpPost("{itemId}/Refresh")] - [Description("Refreshes metadata for an item.")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult RefreshItem( - [FromRoute, Required] Guid itemId, - [FromQuery] MetadataRefreshMode metadataRefreshMode = MetadataRefreshMode.None, - [FromQuery] MetadataRefreshMode imageRefreshMode = MetadataRefreshMode.None, - [FromQuery] bool replaceAllMetadata = false, - [FromQuery] bool replaceAllImages = false) + var refreshOptions = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) { - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - return NotFound(); - } + MetadataRefreshMode = metadataRefreshMode, + ImageRefreshMode = imageRefreshMode, + ReplaceAllImages = replaceAllImages, + ReplaceAllMetadata = replaceAllMetadata, + ForceSave = metadataRefreshMode == MetadataRefreshMode.FullRefresh + || imageRefreshMode == MetadataRefreshMode.FullRefresh + || replaceAllImages + || replaceAllMetadata, + IsAutomated = false + }; - var refreshOptions = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) - { - MetadataRefreshMode = metadataRefreshMode, - ImageRefreshMode = imageRefreshMode, - ReplaceAllImages = replaceAllImages, - ReplaceAllMetadata = replaceAllMetadata, - ForceSave = metadataRefreshMode == MetadataRefreshMode.FullRefresh - || imageRefreshMode == MetadataRefreshMode.FullRefresh - || replaceAllImages - || replaceAllMetadata, - IsAutomated = false - }; - - _providerManager.QueueRefresh(item.Id, refreshOptions, RefreshPriority.High); - return NoContent(); - } + _providerManager.QueueRefresh(item.Id, refreshOptions, RefreshPriority.High); + return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/ItemUpdateController.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs index af3d779f56..504f2fa1d7 100644 --- a/Jellyfin.Api/Controllers/ItemUpdateController.cs +++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs @@ -20,332 +20,386 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// Item update controller. +/// +[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; + /// - /// Item update controller. + /// Initializes a new instance of the class. /// - [Route("")] - [Authorize(Policy = Policies.RequiresElevation)] - public class ItemUpdateController : BaseJellyfinApiController + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + public ItemUpdateController( + IFileSystem fileSystem, + ILibraryManager libraryManager, + IProviderManager providerManager, + ILocalizationManager localizationManager, + IServerConfigurationManager serverConfigurationManager) { - private readonly ILibraryManager _libraryManager; - private readonly IProviderManager _providerManager; - private readonly ILocalizationManager _localizationManager; - private readonly IFileSystem _fileSystem; - private readonly IServerConfigurationManager _serverConfigurationManager; + _libraryManager = libraryManager; + _providerManager = providerManager; + _localizationManager = localizationManager; + _fileSystem = fileSystem; + _serverConfigurationManager = serverConfigurationManager; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - public ItemUpdateController( - IFileSystem fileSystem, - ILibraryManager libraryManager, - IProviderManager providerManager, - ILocalizationManager localizationManager, - IServerConfigurationManager serverConfigurationManager) + /// + /// Updates an item. + /// + /// The item id. + /// The new item properties. + /// Item updated. + /// Item not found. + /// An on success, or a if the item could not be found. + [HttpPost("Items/{itemId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UpdateItem([FromRoute, Required] Guid itemId, [FromBody, Required] BaseItemDto request) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) { - _libraryManager = libraryManager; - _providerManager = providerManager; - _localizationManager = localizationManager; - _fileSystem = fileSystem; - _serverConfigurationManager = serverConfigurationManager; + return NotFound(); } - /// - /// Updates an item. - /// - /// The item id. - /// The new item properties. - /// Item updated. - /// Item not found. - /// An on success, or a if the item could not be found. - [HttpPost("Items/{itemId}")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task UpdateItem([FromRoute, Required] Guid itemId, [FromBody, Required] BaseItemDto request) + 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); + + // Do this first so that metadata savers can pull the updates from the database. + if (request.People is not null) { - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - return NotFound(); - } - - 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); - - // 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()); - } - - UpdateItem(request, item); - - item.OnMetadataChanged(); - - await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); - - if (isLockedChanged && item.IsFolder) - { - var folder = (Folder)item; - - foreach (var child in folder.GetRecursiveChildren()) + _libraryManager.UpdatePeople( + item, + request.People.Select(x => new PersonInfo { - child.IsLocked = newLockData; - await child.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); - } - } - - if (displayOrderChanged) - { - _providerManager.QueueRefresh( - series!.Id, - new MetadataRefreshOptions(new DirectoryService(_fileSystem)) - { - MetadataRefreshMode = MetadataRefreshMode.FullRefresh, - ImageRefreshMode = MetadataRefreshMode.FullRefresh, - ReplaceAllMetadata = true - }, - RefreshPriority.High); - } - - return NoContent(); + Name = x.Name, + Role = x.Role, + Type = x.Type + }).ToList()); } - /// - /// Gets metadata editor info for an item. - /// - /// The item id. - /// Item metadata editor returned. - /// Item not found. - /// An on success containing the metadata editor, or a if the item could not be found. - [HttpGet("Items/{itemId}/MetadataEditor")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult GetMetadataEditorInfo([FromRoute, Required] Guid itemId) + await UpdateItem(request, item).ConfigureAwait(false); + + item.OnMetadataChanged(); + + await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + + if (isLockedChanged && item.IsFolder) { - var item = _libraryManager.GetItemById(itemId); + var folder = (Folder)item; - var info = new MetadataEditorInfo + foreach (var child in folder.GetRecursiveChildren()) { - 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 inheritedContentType = _libraryManager.GetInheritedContentType(item); - var configuredContentType = _libraryManager.GetConfiguredContentType(item); - - if (string.IsNullOrWhiteSpace(inheritedContentType) || - !string.IsNullOrWhiteSpace(configuredContentType)) - { - 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(); - } - } + child.IsLocked = newLockData; + await child.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); } - - return info; } - /// - /// Updates an item's content type. - /// - /// The item id. - /// The content type of the item. - /// Item content type updated. - /// Item not found. - /// An on success, or a if the item could not be found. - [HttpPost("Items/{itemId}/ContentType")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult UpdateItemContentType([FromRoute, Required] Guid itemId, [FromQuery] string? contentType) + if (displayOrderChanged) { - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - return NotFound(); - } - - 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 + _providerManager.QueueRefresh( + series!.Id, + new MetadataRefreshOptions(new DirectoryService(_fileSystem)) { - Name = path, - Value = contentType - }); - } - - _serverConfigurationManager.Configuration.ContentTypes = types.ToArray(); - _serverConfigurationManager.SaveConfiguration(); - return NoContent(); + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ImageRefreshMode = MetadataRefreshMode.FullRefresh, + ReplaceAllMetadata = true + }, + RefreshPriority.High); } - private void UpdateItem(BaseItemDto request, BaseItem item) + return NoContent(); + } + + /// + /// Gets metadata editor info for an item. + /// + /// The item id. + /// Item metadata editor returned. + /// Item not found. + /// An on success containing the metadata editor, or a if the item could not be found. + [HttpGet("Items/{itemId}/MetadataEditor")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult GetMetadataEditorInfo([FromRoute, Required] Guid itemId) + { + var item = _libraryManager.GetItemById(itemId); + + var info = new MetadataEditorInfo { - item.Name = request.Name; - item.ForcedSortName = request.ForcedSortName; + ParentalRatingOptions = _localizationManager.GetParentalRatings().ToList(), + ExternalIdInfos = _providerManager.GetExternalIdInfos(item).ToArray(), + Countries = _localizationManager.GetCountries().ToArray(), + Cultures = _localizationManager.GetCultures().ToArray() + }; - item.OriginalTitle = string.IsNullOrWhiteSpace(request.OriginalTitle) ? null : request.OriginalTitle; + 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); - item.CriticRating = request.CriticRating; - - item.CommunityRating = request.CommunityRating; - item.IndexNumber = request.IndexNumber; - item.ParentIndexNumber = request.ParentIndexNumber; - item.Overview = request.Overview; - item.Genres = request.Genres; - - if (item is Episode episode) + if (string.IsNullOrWhiteSpace(inheritedContentType) || + !string.IsNullOrWhiteSpace(configuredContentType)) { - episode.AirsAfterSeasonNumber = request.AirsAfterSeasonNumber; - episode.AirsBeforeEpisodeNumber = request.AirsBeforeEpisodeNumber; - episode.AirsBeforeSeasonNumber = request.AirsBeforeSeasonNumber; - } + info.ContentTypeOptions = GetContentTypeOptions(true).ToArray(); + info.ContentType = configuredContentType; - item.Tags = request.Tags; - - if (request.Taglines is not null) - { - item.Tagline = request.Taglines.FirstOrDefault(); - } - - if (request.Studios is not null) - { - item.Studios = request.Studios.Select(x => x.Name).ToArray(); - } - - if (request.DateCreated.HasValue) - { - item.DateCreated = NormalizeDateTime(request.DateCreated.Value); - } - - 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) - { - item.ProductionLocations = request.ProductionLocations; - } - - item.PreferredMetadataCountryCode = request.PreferredMetadataCountryCode; - item.PreferredMetadataLanguage = request.PreferredMetadataLanguage; - - if (item is IHasDisplayOrder hasDisplayOrder) - { - hasDisplayOrder.DisplayOrder = request.DisplayOrder; - } - - if (item is IHasAspectRatio hasAspectRatio) - { - hasAspectRatio.AspectRatio = request.AspectRatio; - } - - item.IsLocked = request.LockData ?? false; - - if (request.LockedFields is not null) - { - item.LockedFields = request.LockedFields; - } - - // 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.IsNullOrWhiteSpace(inheritedContentType) + || string.Equals(inheritedContentType, CollectionType.TvShows, StringComparison.OrdinalIgnoreCase)) { - request.ProviderIds.Remove(pair.Key); - } - } - - item.ProviderIds = request.ProviderIds; - - if (item is Video video) - { - video.Video3DFormat = request.Video3DFormat; - } - - if (request.AlbumArtists is not null) - { - if (item is IHasAlbumArtist hasAlbumArtists) - { - hasAlbumArtists.AlbumArtists = request - .AlbumArtists - .Select(i => i.Name) + info.ContentTypeOptions = info.ContentTypeOptions + .Where(i => string.IsNullOrWhiteSpace(i.Value) + || string.Equals(i.Value, CollectionType.TvShows, StringComparison.OrdinalIgnoreCase)) .ToArray(); } } + } - if (request.ArtistItems is not null) + return info; + } + + /// + /// Updates an item's content type. + /// + /// The item id. + /// The content type of the item. + /// Item content type updated. + /// Item not found. + /// An on success, or a if the item could not be found. + [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(); + } + + 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 { - if (item is IHasArtist hasArtists) + Name = path, + Value = contentType + }); + } + + _serverConfigurationManager.Configuration.ContentTypes = types.ToArray(); + _serverConfigurationManager.SaveConfiguration(); + return NoContent(); + } + + private async Task UpdateItem(BaseItemDto request, BaseItem item) + { + item.Name = request.Name; + item.ForcedSortName = request.ForcedSortName; + + 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; + + if (item is Episode episode) + { + episode.AirsAfterSeasonNumber = request.AirsAfterSeasonNumber; + episode.AirsBeforeEpisodeNumber = request.AirsBeforeEpisodeNumber; + episode.AirsBeforeSeasonNumber = request.AirsBeforeSeasonNumber; + } + + if (request.Height is not null && item is LiveTvChannel channel) + { + channel.Height = request.Height.Value; + } + + if (request.Taglines is not null) + { + item.Tagline = request.Taglines.FirstOrDefault(); + } + + if (request.Studios is not null) + { + item.Studios = request.Studios.Select(x => x.Name).ToArray(); + } + + if (request.DateCreated.HasValue) + { + item.DateCreated = NormalizeDateTime(request.DateCreated.Value); + } + + item.EndDate = request.EndDate.HasValue ? NormalizeDateTime(request.EndDate.Value) : null; + item.PremiereDate = request.PremiereDate.HasValue ? NormalizeDateTime(request.PremiereDate.Value) : null; + item.ProductionYear = request.ProductionYear; + + request.OfficialRating = string.IsNullOrWhiteSpace(request.OfficialRating) ? null : request.OfficialRating; + item.OfficialRating = request.OfficialRating; + item.CustomRating = request.CustomRating; + + var currentTags = item.Tags; + var newTags = request.Tags; + var removedTags = currentTags.Except(newTags).ToList(); + var addedTags = newTags.Except(currentTags).ToList(); + item.Tags = newTags; + + if (item is Series rseries) + { + foreach (Season season in rseries.Children) + { + season.OfficialRating = request.OfficialRating; + season.CustomRating = request.CustomRating; + season.Tags = season.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray(); + season.OnMetadataChanged(); + await season.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + + foreach (Episode ep in season.Children) { - hasArtists.Artists = request - .ArtistItems - .Select(i => i.Name) - .ToArray(); + ep.OfficialRating = request.OfficialRating; + ep.CustomRating = request.CustomRating; + ep.Tags = ep.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray(); + ep.OnMetadataChanged(); + await ep.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); } } - - switch (item) + } + else if (item is Season season) + { + foreach (Episode ep in season.Children) { - case Audio song: - song.Album = request.Album; - break; - case MusicVideo musicVideo: - musicVideo.Album = request.Album; - break; - case Series series: + ep.OfficialRating = request.OfficialRating; + ep.CustomRating = request.CustomRating; + ep.Tags = ep.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray(); + ep.OnMetadataChanged(); + await ep.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + } + } + else if (item is MusicAlbum album) + { + foreach (BaseItem track in album.Children) + { + track.OfficialRating = request.OfficialRating; + track.CustomRating = request.CustomRating; + track.Tags = track.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray(); + track.OnMetadataChanged(); + await track.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + } + } + + if (request.ProductionLocations is not null) + { + item.ProductionLocations = request.ProductionLocations; + } + + item.PreferredMetadataCountryCode = request.PreferredMetadataCountryCode; + item.PreferredMetadataLanguage = request.PreferredMetadataLanguage; + + if (item is IHasDisplayOrder hasDisplayOrder) + { + hasDisplayOrder.DisplayOrder = request.DisplayOrder; + } + + if (item is IHasAspectRatio hasAspectRatio) + { + hasAspectRatio.AspectRatio = request.AspectRatio; + } + + item.IsLocked = request.LockData ?? false; + + if (request.LockedFields is not null) + { + item.LockedFields = request.LockedFields; + } + + // 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)) + { + request.ProviderIds.Remove(pair.Key); + } + } + + item.ProviderIds = request.ProviderIds; + + if (item is Video video) + { + video.Video3DFormat = request.Video3DFormat; + } + + if (request.AlbumArtists is not null) + { + if (item is IHasAlbumArtist hasAlbumArtists) + { + hasAlbumArtists.AlbumArtists = request + .AlbumArtists + .Select(i => i.Name) + .ToArray(); + } + } + + if (request.ArtistItems is not null) + { + if (item is IHasArtist hasArtists) + { + 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: { series.Status = GetSeriesStatus(request); @@ -357,93 +411,92 @@ namespace Jellyfin.Api.Controllers break; } - } - } - - private SeriesStatus? GetSeriesStatus(BaseItemDto item) - { - if (string.IsNullOrEmpty(item.Status)) - { - return null; - } - - return (SeriesStatus)Enum.Parse(typeof(SeriesStatus), item.Status, true); - } - - private DateTime NormalizeDateTime(DateTime val) - { - return DateTime.SpecifyKind(val, DateTimeKind.Utc); - } - - private List GetContentTypeOptions(bool isForItem) - { - var list = new List(); - - if (isForItem) - { - list.Add(new NameValuePair - { - Name = "Inherit", - Value = string.Empty - }); - } - - 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 = "Books", - Value = "books" - }); - } - - list.Add(new NameValuePair - { - Name = "HomeVideos", - Value = "homevideos" - }); - list.Add(new NameValuePair - { - Name = "MusicVideos", - Value = "musicvideos" - }); - list.Add(new NameValuePair - { - Name = "Photos", - Value = "photos" - }); - - if (!isForItem) - { - list.Add(new NameValuePair - { - Name = "MixedContent", - Value = string.Empty - }); - } - - foreach (var val in list) - { - val.Name = _localizationManager.GetLocalizedString(val.Name); - } - - return list; } } + + private SeriesStatus? GetSeriesStatus(BaseItemDto item) + { + if (string.IsNullOrEmpty(item.Status)) + { + return null; + } + + return (SeriesStatus)Enum.Parse(typeof(SeriesStatus), item.Status, true); + } + + private DateTime NormalizeDateTime(DateTime val) + { + return DateTime.SpecifyKind(val, DateTimeKind.Utc); + } + + private List GetContentTypeOptions(bool isForItem) + { + var list = new List(); + + if (isForItem) + { + list.Add(new NameValuePair + { + Name = "Inherit", + Value = string.Empty + }); + } + + 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 = "Books", + Value = "books" + }); + } + + list.Add(new NameValuePair + { + Name = "HomeVideos", + Value = "homevideos" + }); + list.Add(new NameValuePair + { + Name = "MusicVideos", + Value = "musicvideos" + }); + list.Add(new NameValuePair + { + Name = "Photos", + Value = "photos" + }); + + if (!isForItem) + { + list.Add(new NameValuePair + { + Name = "MixedContent", + Value = string.Empty + }); + } + + foreach (var val in list) + { + val.Name = _localizationManager.GetLocalizedString(val.Name); + } + + return list; + } } diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs index 717ddc32b3..80128536da 100644 --- a/Jellyfin.Api/Controllers/ItemsController.cs +++ b/Jellyfin.Api/Controllers/ItemsController.cs @@ -1,12 +1,11 @@ using System; using System.ComponentModel.DataAnnotations; using System.Linq; -using System.Threading.Tasks; -using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; +using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; @@ -20,854 +19,866 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// The items controller. +/// +[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 _logger; + private readonly ISessionManager _sessionManager; + /// - /// The items controller. + /// Initializes a new instance of the class. /// - [Route("")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class ItemsController : BaseJellyfinApiController + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + public ItemsController( + IUserManager userManager, + ILibraryManager libraryManager, + ILocalizationManager localization, + IDtoService dtoService, + ILogger logger, + ISessionManager sessionManager) { - private readonly IUserManager _userManager; - private readonly ILibraryManager _libraryManager; - private readonly ILocalizationManager _localization; - private readonly IDtoService _dtoService; - private readonly ILogger _logger; - private readonly ISessionManager _sessionManager; + _userManager = userManager; + _libraryManager = libraryManager; + _localization = localization; + _dtoService = dtoService; + _logger = logger; + _sessionManager = sessionManager; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - public ItemsController( - IUserManager userManager, - ILibraryManager libraryManager, - ILocalizationManager localization, - IDtoService dtoService, - ILogger logger, - ISessionManager sessionManager) + /// + /// Gets items based on a query. + /// + /// The user id supplied as query parameter; this is required when not using an API key. + /// Optional filter by maximum official rating (PG, PG-13, TV-MA, etc). + /// Optional filter by items with theme songs. + /// Optional filter by items with theme videos. + /// Optional filter by items with subtitles. + /// Optional filter by items with special features. + /// Optional filter by items with trailers. + /// Optional. Return items that are siblings of a supplied item. + /// Optional filter by parent index number. + /// Optional filter by items that have or do not have a parental rating. + /// Optional filter by items that are HD or not. + /// Optional filter by items that are 4K or not. + /// Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimited. + /// Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimited. + /// Optional filter by items that are missing episodes or not. + /// Optional filter by items that are unaired episodes or not. + /// Optional filter by minimum community rating. + /// Optional filter by minimum critic rating. + /// Optional. The minimum premiere date. Format = ISO. + /// Optional. The minimum last saved date. Format = ISO. + /// Optional. The minimum last saved date for the current user. Format = ISO. + /// Optional. The maximum premiere date. Format = ISO. + /// Optional filter by items that have an overview or not. + /// Optional filter by items that have an IMDb id or not. + /// Optional filter by items that have a TMDb id or not. + /// Optional filter by items that have a TVDb id or not. + /// Optional filter for live tv movies. + /// Optional filter for live tv series. + /// Optional filter for live tv news. + /// Optional filter for live tv kids. + /// Optional filter for live tv sports. + /// Optional. If specified, results will be filtered by excluding item ids. This allows multiple, comma delimited. + /// Optional. The record index to start at. All items with a lower index will be dropped from the results. + /// Optional. The maximum number of records to return. + /// When searching within folders, this determines whether or not the search will be recursive. true/false. + /// Optional. Filter based on a search term. + /// Sort Order - Ascending, Descending. + /// Specify this to localize the search to a specific item or folder. Omit to use the root. + /// 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. + /// Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited. + /// Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimited. + /// Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes. + /// Optional filter by items that are marked as favorite, or not. + /// Optional filter by MediaType. Allows multiple, comma delimited. + /// Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited. + /// 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. + /// Optional filter by items that are played, or not. + /// Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited. + /// Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited. + /// Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited. + /// Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited. + /// Optional, include user data. + /// Optional, the max number of images to return, per image type. + /// Optional. The image types to include in the output. + /// Optional. If specified, results will be filtered to include only those containing the specified person. + /// Optional. If specified, results will be filtered to include only those containing the specified person id. + /// Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited. + /// Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited. + /// Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimited. + /// Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimited. + /// Optional. If specified, results will be filtered to include only those containing the specified artist id. + /// Optional. If specified, results will be filtered to include only those containing the specified album artist id. + /// Optional. If specified, results will be filtered to include only those containing the specified contributing artist id. + /// Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimited. + /// Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimited. + /// Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited. + /// Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimited. + /// Optional filter by minimum official rating (PG, PG-13, TV-MA, etc). + /// Optional filter by items that are locked. + /// Optional filter by items that are placeholders. + /// Optional filter by items that have official ratings. + /// Whether or not to hide items behind their boxsets. + /// Optional. Filter by the minimum width of the item. + /// Optional. Filter by the minimum height of the item. + /// Optional. Filter by the maximum width of the item. + /// Optional. Filter by the maximum height of the item. + /// Optional filter by items that are 3D, or not. + /// Optional filter by Series Status. Allows multiple, comma delimited. + /// Optional filter by items whose name is sorted equally or greater than a given input string. + /// Optional filter by items whose name is sorted equally than a given input string. + /// Optional filter by items whose name is equally or lesser than a given input string. + /// Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited. + /// Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited. + /// Optional. Enable the total record count. + /// Optional, include image information in output. + /// A with the items. + [HttpGet("Items")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> 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) + { + 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) { - _userManager = userManager; - _libraryManager = libraryManager; - _localization = localization; - _dtoService = dtoService; - _logger = logger; - _sessionManager = sessionManager; + return BadRequest("userId is required"); } - /// - /// Gets items based on a query. - /// - /// The user id supplied as query parameter; this is required when not using an API key. - /// Optional filter by maximum official rating (PG, PG-13, TV-MA, etc). - /// Optional filter by items with theme songs. - /// Optional filter by items with theme videos. - /// Optional filter by items with subtitles. - /// Optional filter by items with special features. - /// Optional filter by items with trailers. - /// Optional. Return items that are siblings of a supplied item. - /// Optional filter by parent index number. - /// Optional filter by items that have or do not have a parental rating. - /// Optional filter by items that are HD or not. - /// Optional filter by items that are 4K or not. - /// Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimited. - /// Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimited. - /// Optional filter by items that are missing episodes or not. - /// Optional filter by items that are unaired episodes or not. - /// Optional filter by minimum community rating. - /// Optional filter by minimum critic rating. - /// Optional. The minimum premiere date. Format = ISO. - /// Optional. The minimum last saved date. Format = ISO. - /// Optional. The minimum last saved date for the current user. Format = ISO. - /// Optional. The maximum premiere date. Format = ISO. - /// Optional filter by items that have an overview or not. - /// Optional filter by items that have an IMDb id or not. - /// Optional filter by items that have a TMDb id or not. - /// Optional filter by items that have a TVDb id or not. - /// Optional filter for live tv movies. - /// Optional filter for live tv series. - /// Optional filter for live tv news. - /// Optional filter for live tv kids. - /// Optional filter for live tv sports. - /// Optional. If specified, results will be filtered by excluding item ids. This allows multiple, comma delimited. - /// Optional. The record index to start at. All items with a lower index will be dropped from the results. - /// Optional. The maximum number of records to return. - /// When searching within folders, this determines whether or not the search will be recursive. true/false. - /// Optional. Filter based on a search term. - /// Sort Order - Ascending, Descending. - /// Specify this to localize the search to a specific item or folder. Omit to use the root. - /// 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. - /// Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited. - /// Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimited. - /// Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes. - /// Optional filter by items that are marked as favorite, or not. - /// Optional filter by MediaType. Allows multiple, comma delimited. - /// Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited. - /// 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. - /// Optional filter by items that are played, or not. - /// Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited. - /// Optional, include user data. - /// Optional, the max number of images to return, per image type. - /// Optional. The image types to include in the output. - /// Optional. If specified, results will be filtered to include only those containing the specified person. - /// Optional. If specified, results will be filtered to include only those containing the specified person id. - /// Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited. - /// Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered to include only those containing the specified artist id. - /// Optional. If specified, results will be filtered to include only those containing the specified album artist id. - /// Optional. If specified, results will be filtered to include only those containing the specified contributing artist id. - /// Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimited. - /// Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited. - /// Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimited. - /// Optional filter by minimum official rating (PG, PG-13, TV-MA, etc). - /// Optional filter by items that are locked. - /// Optional filter by items that are placeholders. - /// Optional filter by items that have official ratings. - /// Whether or not to hide items behind their boxsets. - /// Optional. Filter by the minimum width of the item. - /// Optional. Filter by the minimum height of the item. - /// Optional. Filter by the maximum width of the item. - /// Optional. Filter by the maximum height of the item. - /// Optional filter by items that are 3D, or not. - /// Optional filter by Series Status. Allows multiple, comma delimited. - /// Optional filter by items whose name is sorted equally or greater than a given input string. - /// Optional filter by items whose name is sorted equally than a given input string. - /// Optional filter by items whose name is equally or lesser than a given input string. - /// Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited. - /// Optional. Enable the total record count. - /// Optional, include image information in output. - /// A with the items. - [HttpGet("Items")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> 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) + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + + if (includeItemTypes.Length == 1 + && includeItemTypes[0] == BaseItemKind.BoxSet) { - 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) - { - 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 result; - - if (item is not Folder folder) - { - folder = _libraryManager.GetUserRootFolder(); - } - - string? collectionType = null; - if (folder is IHasCollectionType hasCollectionType) - { - 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) - { - 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; - } - - 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; - } - } - - // 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 => - { - try - { - return _libraryManager.GetArtist(i, new DtoOptions(false)); - } - catch - { - return null; - } - }).Where(i => i is not null).Select(i => i!.Id).ToArray(); - } - - // ExcludeArtistIds - if (excludeArtistIds.Length != 0) - { - query.ExcludeArtistIds = excludeArtistIds; - } - - if (albumIds.Length != 0) - { - query.AlbumIds = albumIds; - } - - // Albums - if (albums.Length != 0) - { - query.AlbumIds = albums.SelectMany(i => - { - return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = new[] { BaseItemKind.MusicAlbum }, Name = i, Limit = 1 }); - }).ToArray(); - } - - // Studios - if (studios.Length != 0) - { - query.StudioIds = studios.Select(i => - { - 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) - { - query.OrderBy = new[] { (ItemSortBy.ProductionYear, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Ascending) }; - } - } - - result = folder.GetItems(query); - } - else - { - var itemsArray = folder.GetChildren(user, true); - result = new QueryResult(itemsArray); - } - - return new QueryResult( - startIndex, - result.TotalRecordCount, - _dtoService.GetBaseItemDtos(result.Items, dtoOptions, user)); + parentId = null; } - /// - /// Gets items based on a query. - /// - /// The user id supplied as query parameter. - /// Optional filter by maximum official rating (PG, PG-13, TV-MA, etc). - /// Optional filter by items with theme songs. - /// Optional filter by items with theme videos. - /// Optional filter by items with subtitles. - /// Optional filter by items with special features. - /// Optional filter by items with trailers. - /// Optional. Return items that are siblings of a supplied item. - /// Optional filter by parent index number. - /// Optional filter by items that have or do not have a parental rating. - /// Optional filter by items that are HD or not. - /// Optional filter by items that are 4K or not. - /// Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimited. - /// Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimited. - /// Optional filter by items that are missing episodes or not. - /// Optional filter by items that are unaired episodes or not. - /// Optional filter by minimum community rating. - /// Optional filter by minimum critic rating. - /// Optional. The minimum premiere date. Format = ISO. - /// Optional. The minimum last saved date. Format = ISO. - /// Optional. The minimum last saved date for the current user. Format = ISO. - /// Optional. The maximum premiere date. Format = ISO. - /// Optional filter by items that have an overview or not. - /// Optional filter by items that have an IMDb id or not. - /// Optional filter by items that have a TMDb id or not. - /// Optional filter by items that have a TVDb id or not. - /// Optional filter for live tv movies. - /// Optional filter for live tv series. - /// Optional filter for live tv news. - /// Optional filter for live tv kids. - /// Optional filter for live tv sports. - /// Optional. If specified, results will be filtered by excluding item ids. This allows multiple, comma delimited. - /// Optional. The record index to start at. All items with a lower index will be dropped from the results. - /// Optional. The maximum number of records to return. - /// When searching within folders, this determines whether or not the search will be recursive. true/false. - /// Optional. Filter based on a search term. - /// Sort Order - Ascending, Descending. - /// Specify this to localize the search to a specific item or folder. Omit to use the root. - /// 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. - /// Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited. - /// Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimited. - /// Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes. - /// Optional filter by items that are marked as favorite, or not. - /// Optional filter by MediaType. Allows multiple, comma delimited. - /// Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited. - /// 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. - /// Optional filter by items that are played, or not. - /// Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited. - /// Optional, include user data. - /// Optional, the max number of images to return, per image type. - /// Optional. The image types to include in the output. - /// Optional. If specified, results will be filtered to include only those containing the specified person. - /// Optional. If specified, results will be filtered to include only those containing the specified person id. - /// Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited. - /// Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered to include only those containing the specified artist id. - /// Optional. If specified, results will be filtered to include only those containing the specified album artist id. - /// Optional. If specified, results will be filtered to include only those containing the specified contributing artist id. - /// Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimited. - /// Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited. - /// Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimited. - /// Optional filter by minimum official rating (PG, PG-13, TV-MA, etc). - /// Optional filter by items that are locked. - /// Optional filter by items that are placeholders. - /// Optional filter by items that have official ratings. - /// Whether or not to hide items behind their boxsets. - /// Optional. Filter by the minimum width of the item. - /// Optional. Filter by the minimum height of the item. - /// Optional. Filter by the maximum width of the item. - /// Optional. Filter by the maximum height of the item. - /// Optional filter by items that are 3D, or not. - /// Optional filter by Series Status. Allows multiple, comma delimited. - /// Optional filter by items whose name is sorted equally or greater than a given input string. - /// Optional filter by items whose name is sorted equally than a given input string. - /// Optional filter by items whose name is equally or lesser than a given input string. - /// Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited. - /// Optional. Enable the total record count. - /// Optional, include image information in output. - /// A with the items. - [HttpGet("Users/{userId}/Items")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> 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) + var item = _libraryManager.GetParentItem(parentId, userId); + QueryResult result; + + if (item is not Folder folder) { - 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); + folder = _libraryManager.GetUserRootFolder(); } - /// - /// Gets items based on a query. - /// - /// The user id. - /// The start index. - /// The item limit. - /// The search term. - /// Specify this to localize the search to a specific item or folder. Omit to use the root. - /// 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. - /// Optional. Filter by MediaType. Allows multiple, comma delimited. - /// Optional. Include user data. - /// Optional. The max number of images to return, per image type. - /// Optional. The image types to include in the output. - /// Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited. - /// Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimited. - /// Optional. Enable the total record count. - /// Optional. Include image information in output. - /// Optional. Whether to exclude the currently active sessions. - /// Items returned. - /// A with the items that are resumable. - [HttpGet("Users/{userId}/Items/Resume")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> 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) + string? collectionType = null; + if (folder is IHasCollectionType hasCollectionType) { - var user = _userManager.GetUserById(userId); - var parentIdGuid = parentId ?? Guid.Empty; - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + collectionType = hasCollectionType.CollectionType; + } - var ancestorIds = Array.Empty(); + if (string.Equals(collectionType, CollectionType.Playlists, StringComparison.OrdinalIgnoreCase)) + { + recursive = true; + includeItemTypes = new[] { BaseItemKind.Playlist }; + } - var excludeFolderIds = user.GetPreferenceValues(PreferenceKind.LatestItemExcludes); - if (parentIdGuid.Equals(default) && excludeFolderIds.Length > 0) + 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) { - ancestorIds = _libraryManager.GetUserRootFolder().GetChildren(user, true) - .Where(i => i is Folder) - .Where(i => !excludeFolderIds.Contains(i.Id)) - .Select(i => i.Id) - .ToArray(); - } - - var excludeItemIds = Array.Empty(); - 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, + IsPlayed = isPlayed, MediaTypes = mediaTypes, - IsVirtualItem = false, - CollapseBoxSetItems = false, - EnableTotalRecordCount = enableTotalRecordCount, - AncestorIds = ancestorIds, 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, - ExcludeItemIds = excludeItemIds - }); + MinDateLastSaved = minDateLastSaved?.ToUniversalTime(), + MinDateLastSavedForUser = minDateLastSavedForUser?.ToUniversalTime(), + MinPremiereDate = minPremiereDate?.ToUniversalTime(), + MaxPremiereDate = maxPremiereDate?.ToUniversalTime(), + }; - var returnItems = _dtoService.GetBaseItemDtos(itemsResult.Items, dtoOptions, user); + if (ids.Length != 0 || !string.IsNullOrWhiteSpace(searchTerm)) + { + query.CollapseBoxSetItems = false; + } - return new QueryResult( - startIndex, - itemsResult.TotalRecordCount, - returnItems); + 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; + } + } + + // Filter by Series Status + if (seriesStatus.Length != 0) + { + query.SeriesStatuses = seriesStatus; + } + + // Exclude Blocked Unrated Items + var blockedUnratedItems = user?.GetPreferenceValues(PreferenceKind.BlockUnratedItems); + if (blockedUnratedItems is not null) + { + query.BlockUnratedItems = blockedUnratedItems; + } + + // 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 => + { + try + { + return _libraryManager.GetArtist(i, new DtoOptions(false)); + } + catch + { + return null; + } + }).Where(i => i is not null).Select(i => i!.Id).ToArray(); + } + + // ExcludeArtistIds + if (excludeArtistIds.Length != 0) + { + query.ExcludeArtistIds = excludeArtistIds; + } + + if (albumIds.Length != 0) + { + query.AlbumIds = albumIds; + } + + // Albums + if (albums.Length != 0) + { + query.AlbumIds = albums.SelectMany(i => + { + return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = new[] { BaseItemKind.MusicAlbum }, Name = i, Limit = 1 }); + }).ToArray(); + } + + // Studios + if (studios.Length != 0) + { + query.StudioIds = studios.Select(i => + { + 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) + { + query.OrderBy = new[] { (ItemSortBy.ProductionYear, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Ascending) }; + } + } + + query.Parent = null; + result = folder.GetItems(query); } + else + { + var itemsArray = folder.GetChildren(user, true); + result = new QueryResult(itemsArray); + } + + return new QueryResult( + startIndex, + result.TotalRecordCount, + _dtoService.GetBaseItemDtos(result.Items, dtoOptions, user)); + } + + /// + /// Gets items based on a query. + /// + /// The user id supplied as query parameter. + /// Optional filter by maximum official rating (PG, PG-13, TV-MA, etc). + /// Optional filter by items with theme songs. + /// Optional filter by items with theme videos. + /// Optional filter by items with subtitles. + /// Optional filter by items with special features. + /// Optional filter by items with trailers. + /// Optional. Return items that are siblings of a supplied item. + /// Optional filter by parent index number. + /// Optional filter by items that have or do not have a parental rating. + /// Optional filter by items that are HD or not. + /// Optional filter by items that are 4K or not. + /// Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimited. + /// Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimited. + /// Optional filter by items that are missing episodes or not. + /// Optional filter by items that are unaired episodes or not. + /// Optional filter by minimum community rating. + /// Optional filter by minimum critic rating. + /// Optional. The minimum premiere date. Format = ISO. + /// Optional. The minimum last saved date. Format = ISO. + /// Optional. The minimum last saved date for the current user. Format = ISO. + /// Optional. The maximum premiere date. Format = ISO. + /// Optional filter by items that have an overview or not. + /// Optional filter by items that have an IMDb id or not. + /// Optional filter by items that have a TMDb id or not. + /// Optional filter by items that have a TVDb id or not. + /// Optional filter for live tv movies. + /// Optional filter for live tv series. + /// Optional filter for live tv news. + /// Optional filter for live tv kids. + /// Optional filter for live tv sports. + /// Optional. If specified, results will be filtered by excluding item ids. This allows multiple, comma delimited. + /// Optional. The record index to start at. All items with a lower index will be dropped from the results. + /// Optional. The maximum number of records to return. + /// When searching within folders, this determines whether or not the search will be recursive. true/false. + /// Optional. Filter based on a search term. + /// Sort Order - Ascending, Descending. + /// Specify this to localize the search to a specific item or folder. Omit to use the root. + /// 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. + /// Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited. + /// Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimited. + /// Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes. + /// Optional filter by items that are marked as favorite, or not. + /// Optional filter by MediaType. Allows multiple, comma delimited. + /// Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited. + /// 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. + /// Optional filter by items that are played, or not. + /// Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited. + /// Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited. + /// Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited. + /// Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited. + /// Optional, include user data. + /// Optional, the max number of images to return, per image type. + /// Optional. The image types to include in the output. + /// Optional. If specified, results will be filtered to include only those containing the specified person. + /// Optional. If specified, results will be filtered to include only those containing the specified person id. + /// Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited. + /// Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited. + /// Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimited. + /// Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimited. + /// Optional. If specified, results will be filtered to include only those containing the specified artist id. + /// Optional. If specified, results will be filtered to include only those containing the specified album artist id. + /// Optional. If specified, results will be filtered to include only those containing the specified contributing artist id. + /// Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimited. + /// Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimited. + /// Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited. + /// Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimited. + /// Optional filter by minimum official rating (PG, PG-13, TV-MA, etc). + /// Optional filter by items that are locked. + /// Optional filter by items that are placeholders. + /// Optional filter by items that have official ratings. + /// Whether or not to hide items behind their boxsets. + /// Optional. Filter by the minimum width of the item. + /// Optional. Filter by the minimum height of the item. + /// Optional. Filter by the maximum width of the item. + /// Optional. Filter by the maximum height of the item. + /// Optional filter by items that are 3D, or not. + /// Optional filter by Series Status. Allows multiple, comma delimited. + /// Optional filter by items whose name is sorted equally or greater than a given input string. + /// Optional filter by items whose name is sorted equally than a given input string. + /// Optional filter by items whose name is equally or lesser than a given input string. + /// Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited. + /// Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited. + /// Optional. Enable the total record count. + /// Optional, include image information in output. + /// A with the items. + [HttpGet("Users/{userId}/Items")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> 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); + } + + /// + /// Gets items based on a query. + /// + /// The user id. + /// The start index. + /// The item limit. + /// The search term. + /// Specify this to localize the search to a specific item or folder. Omit to use the root. + /// 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. + /// Optional. Filter by MediaType. Allows multiple, comma delimited. + /// Optional. Include user data. + /// Optional. The max number of images to return, per image type. + /// Optional. The image types to include in the output. + /// Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited. + /// Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimited. + /// Optional. Enable the total record count. + /// Optional. Include image information in output. + /// Optional. Whether to exclude the currently active sessions. + /// Items returned. + /// A with the items that are resumable. + [HttpGet("Users/{userId}/Items/Resume")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> 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 parentIdGuid = parentId ?? Guid.Empty; + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + + var ancestorIds = Array.Empty(); + + var excludeFolderIds = user.GetPreferenceValues(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(); + } + + var excludeItemIds = Array.Empty(); + 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( + startIndex, + itemsResult.TotalRecordCount, + returnItems); } } diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs index 196d509fbc..46c0a8d527 100644 --- a/Jellyfin.Api/Controllers/LibraryController.cs +++ b/Jellyfin.Api/Controllers/LibraryController.cs @@ -4,18 +4,18 @@ using System.ComponentModel.DataAnnotations; using System.Globalization; using System.IO; using System.Linq; -using System.Net; -using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.LibraryDtos; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Extensions; +using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Progress; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; @@ -37,301 +37,368 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Controllers -{ - /// - /// Library Controller. - /// - [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 _logger; - private readonly IServerConfigurationManager _serverConfigurationManager; +namespace Jellyfin.Api.Controllers; - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - public LibraryController( - IProviderManager providerManager, - ILibraryManager libraryManager, - IUserManager userManager, - IDtoService dtoService, - IActivityManager activityManager, - ILocalizationManager localization, - ILibraryMonitor libraryMonitor, - ILogger logger, - IServerConfigurationManager serverConfigurationManager) +/// +/// Library Controller. +/// +[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 _logger; + private readonly IServerConfigurationManager _serverConfigurationManager; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + public LibraryController( + IProviderManager providerManager, + ILibraryManager libraryManager, + IUserManager userManager, + IDtoService dtoService, + IActivityManager activityManager, + ILocalizationManager localization, + ILibraryMonitor libraryMonitor, + ILogger logger, + IServerConfigurationManager serverConfigurationManager) + { + _providerManager = providerManager; + _libraryManager = libraryManager; + _userManager = userManager; + _dtoService = dtoService; + _activityManager = activityManager; + _localization = localization; + _libraryMonitor = libraryMonitor; + _logger = logger; + _serverConfigurationManager = serverConfigurationManager; + } + + /// + /// Get the original file of an item. + /// + /// The item id. + /// File stream returned. + /// Item not found. + /// A with the original file. + [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) { - _providerManager = providerManager; - _libraryManager = libraryManager; - _userManager = userManager; - _dtoService = dtoService; - _activityManager = activityManager; - _localization = localization; - _libraryMonitor = libraryMonitor; - _logger = logger; - _serverConfigurationManager = serverConfigurationManager; + return NotFound(); } - /// - /// Get the original file of an item. - /// - /// The item id. - /// File stream returned. - /// Item not found. - /// A with the original file. - [HttpGet("Items/{itemId}/File")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesFile("video/*", "audio/*")] - public ActionResult GetFile([FromRoute, Required] Guid itemId) + return PhysicalFile(item.Path, MimeTypes.GetMimeType(item.Path), true); + } + + /// + /// Gets critic review for an item. + /// + /// Critic reviews returned. + /// The list of critic reviews. + [HttpGet("Items/{itemId}/CriticReviews")] + [Authorize] + [Obsolete("This endpoint is obsolete.")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> GetCriticReviews() + { + return new QueryResult(); + } + + /// + /// Get theme songs for an item. + /// + /// The item id. + /// Optional. Filter by user id, and attach user data. + /// Optional. Determines whether or not parent items should be searched for theme media. + /// Theme songs returned. + /// Item not found. + /// The item theme songs. + [HttpGet("Items/{itemId}/ThemeSongs")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult 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) { - var item = _libraryManager.GetItemById(itemId); + return NotFound("Item not found."); + } + + IEnumerable themeItems; + + while (true) + { + themeItems = item.GetThemeSongs(); + + if (themeItems.Any() || !inheritFromParent) + { + break; + } + + var parent = item.GetParent(); + if (parent is null) + { + break; + } + + item = parent; + } + + var dtoOptions = new DtoOptions().AddClientFields(User); + var items = themeItems + .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)) + .ToArray(); + + return new ThemeMediaResult + { + Items = items, + TotalRecordCount = items.Length, + OwnerId = item.Id + }; + } + + /// + /// Get theme videos for an item. + /// + /// The item id. + /// Optional. Filter by user id, and attach user data. + /// Optional. Determines whether or not parent items should be searched for theme media. + /// Theme videos returned. + /// Item not found. + /// The item theme videos. + [HttpGet("Items/{itemId}/ThemeVideos")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult 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."); + } + + IEnumerable themeItems; + + while (true) + { + themeItems = item.GetThemeVideos(); + + if (themeItems.Any() || !inheritFromParent) + { + break; + } + + var parent = item.GetParent(); + if (parent is null) + { + break; + } + + item = parent; + } + + var dtoOptions = new DtoOptions().AddClientFields(User); + var items = themeItems + .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)) + .ToArray(); + + return new ThemeMediaResult + { + Items = items, + TotalRecordCount = items.Length, + OwnerId = item.Id + }; + } + + /// + /// Get theme songs and videos for an item. + /// + /// The item id. + /// Optional. Filter by user id, and attach user data. + /// Optional. Determines whether or not parent items should be searched for theme media. + /// Theme songs and videos returned. + /// Item not found. + /// The item theme videos. + [HttpGet("Items/{itemId}/ThemeMedia")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult GetThemeMedia( + [FromRoute, Required] Guid itemId, + [FromQuery] Guid? userId, + [FromQuery] bool inheritFromParent = false) + { + var themeSongs = GetThemeSongs( + itemId, + userId, + inheritFromParent); + + var themeVideos = GetThemeVideos( + itemId, + userId, + inheritFromParent); + + if (themeSongs.Result is NotFoundObjectResult || themeVideos.Result is NotFoundObjectResult) + { + return NotFound(); + } + + return new AllThemeMediaResult + { + ThemeSongsResult = themeSongs?.Value, + ThemeVideosResult = themeVideos?.Value, + SoundtrackSongsResult = new ThemeMediaResult() + }; + } + + /// + /// Starts a library scan. + /// + /// Library scan started. + /// A . + [HttpPost("Library/Refresh")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task RefreshLibrary() + { + try + { + await _libraryManager.ValidateMediaLibrary(new SimpleProgress(), CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error refreshing library"); + } + + return NoContent(); + } + + /// + /// Deletes an item from the library and filesystem. + /// + /// The item id. + /// Item deleted. + /// Unauthorized access. + /// A . + [HttpDelete("Items/{itemId}")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult DeleteItem(Guid itemId) + { + var isApiKey = User.GetIsApiKey(); + var userId = User.GetUserId(); + var user = !isApiKey && !userId.Equals(default) + ? _userManager.GetUserById(userId) ?? throw new ResourceNotFoundException() + : null; + if (!isApiKey && user is null) + { + return Unauthorized("Unauthorized access"); + } + + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + return NotFound(); + } + + if (user is not null && !item.CanDelete(user)) + { + return Unauthorized("Unauthorized access"); + } + + _libraryManager.DeleteItem( + item, + new DeleteOptions { DeleteFileLocation = true }, + true); + + return NoContent(); + } + + /// + /// Deletes items from the library and filesystem. + /// + /// The item ids. + /// Items deleted. + /// Unauthorized access. + /// A . + [HttpDelete("Items")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult DeleteItems([FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) + { + var isApiKey = User.GetIsApiKey(); + var userId = User.GetUserId(); + var user = !isApiKey && !userId.Equals(default) + ? _userManager.GetUserById(userId) ?? throw new ResourceNotFoundException() + : null; + + if (!isApiKey && user is null) + { + return Unauthorized("Unauthorized access"); + } + + foreach (var i in ids) + { + var item = _libraryManager.GetItemById(i); if (item is null) { return NotFound(); } - return PhysicalFile(item.Path, MimeTypes.GetMimeType(item.Path), true); - } - - /// - /// Gets critic review for an item. - /// - /// Critic reviews returned. - /// The list of critic reviews. - [HttpGet("Items/{itemId}/CriticReviews")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [Obsolete("This endpoint is obsolete.")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> GetCriticReviews() - { - return new QueryResult(); - } - - /// - /// Get theme songs for an item. - /// - /// The item id. - /// Optional. Filter by user id, and attach user data. - /// Optional. Determines whether or not parent items should be searched for theme media. - /// Theme songs returned. - /// Item not found. - /// The item theme songs. - [HttpGet("Items/{itemId}/ThemeSongs")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult 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) - { - return NotFound("Item not found."); - } - - IEnumerable themeItems; - - while (true) - { - themeItems = item.GetThemeSongs(); - - if (themeItems.Any() || !inheritFromParent) - { - break; - } - - var parent = item.GetParent(); - if (parent is null) - { - break; - } - - item = parent; - } - - var dtoOptions = new DtoOptions().AddClientFields(User); - var items = themeItems - .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)) - .ToArray(); - - return new ThemeMediaResult - { - Items = items, - TotalRecordCount = items.Length, - OwnerId = item.Id - }; - } - - /// - /// Get theme videos for an item. - /// - /// The item id. - /// Optional. Filter by user id, and attach user data. - /// Optional. Determines whether or not parent items should be searched for theme media. - /// Theme videos returned. - /// Item not found. - /// The item theme videos. - [HttpGet("Items/{itemId}/ThemeVideos")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult 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) - { - return NotFound("Item not found."); - } - - IEnumerable themeItems; - - while (true) - { - themeItems = item.GetThemeVideos(); - - if (themeItems.Any() || !inheritFromParent) - { - break; - } - - var parent = item.GetParent(); - if (parent is null) - { - break; - } - - item = parent; - } - - var dtoOptions = new DtoOptions().AddClientFields(User); - var items = themeItems - .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)) - .ToArray(); - - return new ThemeMediaResult - { - Items = items, - TotalRecordCount = items.Length, - OwnerId = item.Id - }; - } - - /// - /// Get theme songs and videos for an item. - /// - /// The item id. - /// Optional. Filter by user id, and attach user data. - /// Optional. Determines whether or not parent items should be searched for theme media. - /// Theme songs and videos returned. - /// Item not found. - /// The item theme videos. - [HttpGet("Items/{itemId}/ThemeMedia")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult 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() - }; - } - - /// - /// Starts a library scan. - /// - /// Library scan started. - /// A . - [HttpPost("Library/Refresh")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task RefreshLibrary() - { - try - { - await _libraryManager.ValidateMediaLibrary(new SimpleProgress(), CancellationToken.None).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error refreshing library"); - } - - return NoContent(); - } - - /// - /// Deletes an item from the library and filesystem. - /// - /// The item id. - /// Item deleted. - /// Unauthorized access. - /// A . - [HttpDelete("Items/{itemId}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [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)) + if (user is not null && !item.CanDelete(user)) { return Unauthorized("Unauthorized access"); } @@ -340,470 +407,441 @@ namespace Jellyfin.Api.Controllers item, new DeleteOptions { DeleteFileLocation = true }, true); - - return NoContent(); } - /// - /// Deletes items from the library and filesystem. - /// - /// The item ids. - /// Items deleted. - /// Unauthorized access. - /// A . - [HttpDelete("Items")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - public ActionResult DeleteItems([FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) + return NoContent(); + } + + /// + /// Get item counts. + /// + /// Optional. Get counts from a specific user's library. + /// Optional. Get counts of favorite items. + /// Item counts returned. + /// Item counts. + [HttpGet("Items/Counts")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult GetItemCounts( + [FromQuery] Guid? userId, + [FromQuery] bool? isFavorite) + { + userId = RequestHelpers.GetUserId(User, userId); + var user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + + var counts = new ItemCounts { - if (ids.Length == 0) - { - return NoContent(); - } + 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) + }; - foreach (var i in ids) - { - var item = _libraryManager.GetItemById(i); - var user = _userManager.GetUserById(User.GetUserId()); + return counts; + } - if (!item.CanDelete(user)) - { - if (ids.Length > 1) - { - return Unauthorized("Unauthorized access"); - } + /// + /// Gets all parents of an item. + /// + /// The item id. + /// Optional. Filter by user id, and attach user data. + /// Item parents returned. + /// Item not found. + /// Item parents. + [HttpGet("Items/{itemId}/Ancestors")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult> GetAncestors([FromRoute, Required] Guid itemId, [FromQuery] Guid? userId) + { + var item = _libraryManager.GetItemById(itemId); + userId = RequestHelpers.GetUserId(User, userId); - continue; - } - - _libraryManager.DeleteItem( - item, - new DeleteOptions { DeleteFileLocation = true }, - true); - } - - return NoContent(); + if (item is null) + { + return NotFound("Item not found"); } - /// - /// Get item counts. - /// - /// Optional. Get counts from a specific user's library. - /// Optional. Get counts of favorite items. - /// Item counts returned. - /// Item counts. - [HttpGet("Items/Counts")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult GetItemCounts( - [FromQuery] Guid? userId, - [FromQuery] bool? isFavorite) + var baseItemDtos = new List(); + + var user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + + var dtoOptions = new DtoOptions().AddClientFields(User); + BaseItem? parent = item.GetParent(); + + while (parent is not null) { - 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; - } - - /// - /// Gets all parents of an item. - /// - /// The item id. - /// Optional. Filter by user id, and attach user data. - /// Item parents returned. - /// Item not found. - /// Item parents. - [HttpGet("Items/{itemId}/Ancestors")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult> 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(); - - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - - var dtoOptions = new DtoOptions().AddClientFields(User); - BaseItem? parent = item.GetParent(); - - while (parent is not null) - { - if (user is not null) - { - parent = TranslateParentItem(parent, user); - } - - baseItemDtos.Add(_dtoService.GetBaseItemDto(parent, dtoOptions, user)); - - parent = parent?.GetParent(); - } - - return baseItemDtos; - } - - /// - /// Gets a list of physical paths from virtual folders. - /// - /// Physical paths returned. - /// List of physical paths. - [HttpGet("Library/PhysicalPaths")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> GetPhysicalPaths() - { - return Ok(_libraryManager.RootFolder.Children - .SelectMany(c => c.PhysicalLocations)); - } - - /// - /// Gets all user media folders. - /// - /// Optional. Filter by folders that are marked hidden, or not. - /// Media folders returned. - /// List of user media folders. - [HttpGet("Library/MediaFolders")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> 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; - - items = items.Where(i => i.IsHidden == val).ToList(); - } - - var dtoOptions = new DtoOptions().AddClientFields(User); - var resultArray = _dtoService.GetBaseItemDtos(items, dtoOptions); - return new QueryResult(resultArray); - } - - /// - /// Reports that new episodes of a series have been added by an external source. - /// - /// The tvdbId. - /// Report success. - /// A . - [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(); - - foreach (var item in series) - { - _libraryMonitor.ReportFileSystemChanged(item.Path); - } - - return NoContent(); - } - - /// - /// Reports that new movies have been added by an external source. - /// - /// The tmdbId. - /// The imdbId. - /// Report success. - /// A . - [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(); - } - - foreach (var item in movies) - { - _libraryMonitor.ReportFileSystemChanged(item.Path); - } - - return NoContent(); - } - - /// - /// Reports that new movies have been added by an external source. - /// - /// The update paths. - /// Report success. - /// A . - [HttpPost("Library/Media/Updated")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult PostUpdatedMedia([FromBody, Required] MediaUpdateInfoDto dto) - { - foreach (var item in dto.Updates) - { - _libraryMonitor.ReportFileSystemChanged(item.Path ?? throw new ArgumentException("Item path can't be null.")); - } - - return NoContent(); - } - - /// - /// Downloads item media. - /// - /// The item id. - /// Media downloaded. - /// Item not found. - /// A containing the media stream. - /// User can't download or item can't be downloaded. - [HttpGet("Items/{itemId}/Download")] - [Authorize(Policy = Policies.Download)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesFile("video/*", "audio/*")] - public async Task GetDownload([FromRoute, Required] Guid itemId) - { - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - return NotFound(); - } - - var user = _userManager.GetUserById(User.GetUserId()); - if (user is not null) { - if (!item.CanDownload(user)) + parent = TranslateParentItem(parent, user); + if (parent is null) { - throw new ArgumentException("Item does not support downloading"); - } - } - else - { - if (!item.CanDownload()) - { - throw new ArgumentException("Item does not support downloading"); + break; } } - if (user is not null) - { - await LogDownloadAsync(item, user).ConfigureAwait(false); - } + baseItemDtos.Add(_dtoService.GetBaseItemDto(parent, dtoOptions, user)); - // 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); + parent = parent?.GetParent(); } - /// - /// Gets similar items. - /// - /// The item id. - /// Exclude artist ids. - /// Optional. Filter by user id, and attach user data. - /// Optional. The maximum number of records to return. - /// 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. - /// Similar items returned. - /// A containing the similar items. - [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> GetSimilarItems( - [FromRoute, Required] Guid itemId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds, - [FromQuery] Guid? userId, - [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields) + return baseItemDtos; + } + + /// + /// Gets a list of physical paths from virtual folders. + /// + /// Physical paths returned. + /// List of physical paths. + [HttpGet("Library/PhysicalPaths")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> GetPhysicalPaths() + { + return Ok(_libraryManager.RootFolder.Children + .SelectMany(c => c.PhysicalLocations)); + } + + /// + /// Gets all user media folders. + /// + /// Optional. Filter by folders that are marked hidden, or not. + /// Media folders returned. + /// List of user media folders. + [HttpGet("Library/MediaFolders")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> GetMediaFolders([FromQuery] bool? isHidden) + { + var items = _libraryManager.GetUserRootFolder().Children.Concat(_libraryManager.RootFolder.VirtualChildren).OrderBy(i => i.SortName).ToList(); + + if (isHidden.HasValue) { - var item = itemId.Equals(default) - ? (userId is null || userId.Value.Equals(default) - ? _libraryManager.RootFolder - : _libraryManager.GetUserRootFolder()) - : _libraryManager.GetItemById(itemId); + var val = isHidden.Value; - if (item is Episode || (item is IItemByName && item is not MusicArtist)) - { - return new QueryResult(); - } - - var user = userId is null || 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(); - if (isMovie.Value) - { - includeItemTypes.Add(BaseItemKind.Movie); - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) - { - includeItemTypes.Add(BaseItemKind.Trailer); - includeItemTypes.Add(BaseItemKind.LiveTvProgram); - } - } - else if (isSeries.Value) - { - includeItemTypes.Add(BaseItemKind.Series); - } - else - { - // For non series and movie types these columns are typically null - // isSeries = null; - isMovie = null; - includeItemTypes.Add(item.GetBaseItemKind()); - } - - var query = new InternalItemsQuery(user) - { - Genres = item.Genres, - 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; - } - - List itemsResult = _libraryManager.GetItemList(query); - - var returnList = _dtoService.GetBaseItemDtos(itemsResult, dtoOptions, user); - - return new QueryResult( - query.StartIndex, - itemsResult.Count, - returnList); + items = items.Where(i => i.IsHidden == val).ToList(); } - /// - /// Gets the library options info. - /// - /// Library content type. - /// Whether this is a new library. - /// Library options info returned. - /// Library options info. - [HttpGet("Libraries/AvailableOptions")] - [Authorize(Policy = Policies.FirstTimeSetupOrDefault)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult GetLibraryOptionsInfo( - [FromQuery] string? libraryContentType, - [FromQuery] bool isNewLibrary = false) + var dtoOptions = new DtoOptions().AddClientFields(User); + var resultArray = _dtoService.GetBaseItemDtos(items, dtoOptions); + return new QueryResult(resultArray); + } + + /// + /// Reports that new episodes of a series have been added by an external source. + /// + /// The tvdbId. + /// Report success. + /// A . + [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 { - var result = new LibraryOptionsResultDto(); - - var types = GetRepresentativeItemTypes(libraryContentType); - var typesList = types.ToList(); - - var plugins = _providerManager.GetAllMetadataPlugins() - .Where(i => types.Contains(i.ItemType, StringComparison.OrdinalIgnoreCase)) - .OrderBy(i => typesList.IndexOf(i.ItemType)) - .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 typeOptions = new List(); - - foreach (var type in types) + IncludeItemTypes = new[] { BaseItemKind.Series }, + DtoOptions = new DtoOptions(false) { - TypeOptions.DefaultImageOptions.TryGetValue(type, out var defaultImageOptions); + EnableImages = false + } + }).Where(i => string.Equals(tvdbId, i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Tvdb), StringComparison.OrdinalIgnoreCase)).ToArray(); - typeOptions.Add(new LibraryTypeOptionsDto - { - Type = type, + foreach (var item in series) + { + _libraryMonitor.ReportFileSystemChanged(item.Path); + } - MetadataFetchers = plugins + return NoContent(); + } + + /// + /// Reports that new movies have been added by an external source. + /// + /// The tmdbId. + /// The imdbId. + /// Report success. + /// A . + [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) + { + 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(); + } + + foreach (var item in movies) + { + _libraryMonitor.ReportFileSystemChanged(item.Path); + } + + return NoContent(); + } + + /// + /// Reports that new movies have been added by an external source. + /// + /// The update paths. + /// Report success. + /// A . + [HttpPost("Library/Media/Updated")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult PostUpdatedMedia([FromBody, Required] MediaUpdateInfoDto dto) + { + foreach (var item in dto.Updates) + { + _libraryMonitor.ReportFileSystemChanged(item.Path ?? throw new ArgumentException("Item path can't be null.")); + } + + return NoContent(); + } + + /// + /// Downloads item media. + /// + /// The item id. + /// Media downloaded. + /// Item not found. + /// A containing the media stream. + /// User can't download or item can't be downloaded. + [HttpGet("Items/{itemId}/Download")] + [Authorize(Policy = Policies.Download)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesFile("video/*", "audio/*")] + public async Task GetDownload([FromRoute, Required] Guid itemId) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + return NotFound(); + } + + var user = _userManager.GetUserById(User.GetUserId()); + + if (user is not null) + { + if (!item.CanDownload(user)) + { + throw new ArgumentException("Item does not support downloading"); + } + } + else + { + if (!item.CanDownload()) + { + throw new ArgumentException("Item does not support downloading"); + } + } + + 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); + } + + /// + /// Gets similar items. + /// + /// The item id. + /// Exclude artist ids. + /// Optional. Filter by user id, and attach user data. + /// Optional. The maximum number of records to return. + /// 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. + /// Similar items returned. + /// A containing the similar items. + [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> 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(); + } + + if (item is Episode || (item is IItemByName && item is not MusicArtist)) + { + return new QueryResult(); + } + + 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(); + if (isMovie.Value) + { + includeItemTypes.Add(BaseItemKind.Movie); + if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) + { + includeItemTypes.Add(BaseItemKind.Trailer); + includeItemTypes.Add(BaseItemKind.LiveTvProgram); + } + } + else if (isSeries.Value) + { + includeItemTypes.Add(BaseItemKind.Series); + } + else + { + // For non series and movie types these columns are typically null + // isSeries = null; + isMovie = null; + includeItemTypes.Add(item.GetBaseItemKind()); + } + + var query = new InternalItemsQuery(user) + { + Genres = item.Genres, + 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; + } + + List itemsResult = _libraryManager.GetItemList(query); + + var returnList = _dtoService.GetBaseItemDtos(itemsResult, dtoOptions, user); + + return new QueryResult( + query.StartIndex, + itemsResult.Count, + returnList); + } + + /// + /// Gets the library options info. + /// + /// Library content type. + /// Whether this is a new library. + /// Library options info returned. + /// Library options info. + [HttpGet("Libraries/AvailableOptions")] + [Authorize(Policy = Policies.FirstTimeSetupOrDefault)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult GetLibraryOptionsInfo( + [FromQuery] string? libraryContentType, + [FromQuery] bool isNewLibrary = false) + { + var result = new LibraryOptionsResultDto(); + + var types = GetRepresentativeItemTypes(libraryContentType); + var typesList = types.ToList(); + + var plugins = _providerManager.GetAllMetadataPlugins() + .Where(i => types.Contains(i.ItemType, StringComparison.OrdinalIgnoreCase)) + .OrderBy(i => typesList.IndexOf(i.ItemType)) + .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 typeOptions = new List(); + + foreach (var type in types) + { + TypeOptions.DefaultImageOptions.TryGetValue(type, out var defaultImageOptions); + + typeOptions.Add(new LibraryTypeOptionsDto + { + Type = type, + + MetadataFetchers = plugins .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.MetadataFetcher)) .Select(i => new LibraryOptionInfoDto @@ -814,7 +852,7 @@ namespace Jellyfin.Api.Controllers .DistinctBy(i => i.Name, StringComparer.OrdinalIgnoreCase) .ToArray(), - ImageFetchers = plugins + ImageFetchers = plugins .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.ImageFetcher)) .Select(i => new LibraryOptionInfoDto @@ -825,148 +863,135 @@ namespace Jellyfin.Api.Controllers .DistinctBy(i => i.Name, StringComparer.OrdinalIgnoreCase) .ToArray(), - SupportedImageTypes = plugins + SupportedImageTypes = plugins .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) .SelectMany(i => i.SupportedImageTypes ?? Array.Empty()) .Distinct() .ToArray(), - DefaultImageOptions = defaultImageOptions ?? Array.Empty() - }); - } - - result.TypeOptions = typeOptions.ToArray(); - - return result; + DefaultImageOptions = defaultImageOptions ?? Array.Empty() + }); } - private int GetCount(BaseItemKind itemKind, User? user, bool? isFavorite) + result.TypeOptions = typeOptions.ToArray(); + + return result; + } + + 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 - } - }; - - 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 async Task LogDownloadAsync(BaseItem item, User user) - { - 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); + EnableImages = false } - catch - { - // Logged at lower levels - } - } + }; - private static string[] GetRepresentativeItemTypes(string? contentType) + 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 async Task LogDownloadAsync(BaseItem item, User user) + { + try { - return contentType switch + await _activityManager.CreateAsync(new ActivityLog( + string.Format(CultureInfo.InvariantCulture, _localization.GetLocalizedString("UserDownloadingItemWithValues"), user.Username, item.Name), + "UserDownloadingContent", + User.GetUserId()) { - 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" } - }; + ShortOverview = string.Format(CultureInfo.InvariantCulture, _localization.GetLocalizedString("AppDeviceValues"), User.GetClient(), User.GetDevice()), + }).ConfigureAwait(false); } - - private bool IsSaverEnabledByDefault(string name, string[] itemTypes, bool isNewLibrary) + catch { - if (isNewLibrary) - { - return false; - } - - 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 (string.Equals(name, "TheMovieDb", 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) - || string.Equals(name, "TheAudioDB", StringComparison.OrdinalIgnoreCase) - || string.Equals(name, "MusicBrainz", StringComparison.OrdinalIgnoreCase); - } - - var metadataOptions = _serverConfigurationManager.Configuration.MetadataOptions - .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) - .ToArray(); - - return metadataOptions.Length == 0 - || metadataOptions.Any(i => !i.DisabledMetadataFetchers.Contains(name, StringComparison.OrdinalIgnoreCase)); - } - - private bool IsImageFetcherEnabledByDefault(string name, string type, bool isNewLibrary) - { - if (isNewLibrary) - { - if (string.Equals(name, "TheMovieDb", StringComparison.OrdinalIgnoreCase)) - { - return !string.Equals(type, "Series", StringComparison.OrdinalIgnoreCase) - && !string.Equals(type, "Season", StringComparison.OrdinalIgnoreCase) - && !string.Equals(type, "Episode", StringComparison.OrdinalIgnoreCase) - && !string.Equals(type, "MusicVideo", StringComparison.OrdinalIgnoreCase); - } - - return string.Equals(name, "TheTVDB", StringComparison.OrdinalIgnoreCase) - || string.Equals(name, "Screen Grabber", StringComparison.OrdinalIgnoreCase) - || string.Equals(name, "TheAudioDB", StringComparison.OrdinalIgnoreCase) - || string.Equals(name, "Image Extractor", StringComparison.OrdinalIgnoreCase); - } - - var metadataOptions = _serverConfigurationManager.Configuration.MetadataOptions - .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) - .ToArray(); - - if (metadataOptions.Length == 0) - { - return true; - } - - return metadataOptions.Any(i => !i.DisabledImageFetchers.Contains(name, StringComparison.OrdinalIgnoreCase)); + // Logged at lower levels } } + + 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" } + }; + } + + private bool IsSaverEnabledByDefault(string name, string[] itemTypes, bool isNewLibrary) + { + if (isNewLibrary) + { + return false; + } + + 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 (string.Equals(name, "TheMovieDb", 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) + || string.Equals(name, "TheAudioDB", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "MusicBrainz", StringComparison.OrdinalIgnoreCase); + } + + var metadataOptions = _serverConfigurationManager.GetMetadataOptionsForType(type); + return metadataOptions is null || !metadataOptions.DisabledMetadataFetchers.Contains(name, StringComparison.OrdinalIgnoreCase); + } + + private bool IsImageFetcherEnabledByDefault(string name, string type, bool isNewLibrary) + { + if (isNewLibrary) + { + if (string.Equals(name, "TheMovieDb", StringComparison.OrdinalIgnoreCase)) + { + return !string.Equals(type, "Series", StringComparison.OrdinalIgnoreCase) + && !string.Equals(type, "Season", StringComparison.OrdinalIgnoreCase) + && !string.Equals(type, "Episode", StringComparison.OrdinalIgnoreCase) + && !string.Equals(type, "MusicVideo", StringComparison.OrdinalIgnoreCase); + } + + return string.Equals(name, "TheTVDB", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "Screen Grabber", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "TheAudioDB", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "Image Extractor", StringComparison.OrdinalIgnoreCase); + } + + var metadataOptions = _serverConfigurationManager.GetMetadataOptionsForType(type); + return metadataOptions is null || !metadataOptions.DisabledImageFetchers.Contains(name, StringComparison.OrdinalIgnoreCase); + } } diff --git a/Jellyfin.Api/Controllers/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs index 1c23940556..b012ff42eb 100644 --- a/Jellyfin.Api/Controllers/LibraryStructureController.cs +++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs @@ -20,308 +20,307 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// The library structure controller. +/// +[Route("Library/VirtualFolders")] +[Authorize(Policy = Policies.FirstTimeSetupOrElevated)] +public class LibraryStructureController : BaseJellyfinApiController { + private readonly IServerApplicationPaths _appPaths; + private readonly ILibraryManager _libraryManager; + private readonly ILibraryMonitor _libraryMonitor; + /// - /// The library structure controller. + /// Initializes a new instance of the class. /// - [Route("Library/VirtualFolders")] - [Authorize(Policy = Policies.FirstTimeSetupOrElevated)] - public class LibraryStructureController : BaseJellyfinApiController + /// Instance of interface. + /// Instance of interface. + /// Instance of interface. + public LibraryStructureController( + IServerConfigurationManager serverConfigurationManager, + ILibraryManager libraryManager, + ILibraryMonitor libraryMonitor) { - private readonly IServerApplicationPaths _appPaths; - private readonly ILibraryManager _libraryManager; - private readonly ILibraryMonitor _libraryMonitor; + _appPaths = serverConfigurationManager.ApplicationPaths; + _libraryManager = libraryManager; + _libraryMonitor = libraryMonitor; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of interface. - /// Instance of interface. - /// Instance of interface. - public LibraryStructureController( - IServerConfigurationManager serverConfigurationManager, - ILibraryManager libraryManager, - ILibraryMonitor libraryMonitor) + /// + /// Gets all virtual folders. + /// + /// Virtual folders retrieved. + /// An with the virtual folders. + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> GetVirtualFolders() + { + return _libraryManager.GetVirtualFolders(true); + } + + /// + /// Adds a virtual folder. + /// + /// The name of the virtual folder. + /// The type of the collection. + /// The paths of the virtual folder. + /// The library options. + /// Whether to refresh the library. + /// Folder added. + /// A . + [HttpPost] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task 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(); + + if (paths is not null && paths.Length > 0) { - _appPaths = serverConfigurationManager.ApplicationPaths; - _libraryManager = libraryManager; - _libraryMonitor = libraryMonitor; + libraryOptions.PathInfos = paths.Select(i => new MediaPathInfo(i)).ToArray(); } - /// - /// Gets all virtual folders. - /// - /// Virtual folders retrieved. - /// An with the virtual folders. - [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> GetVirtualFolders() + await _libraryManager.AddVirtualFolder(name, collectionType, libraryOptions, refreshLibrary).ConfigureAwait(false); + + return NoContent(); + } + + /// + /// Removes a virtual folder. + /// + /// The name of the folder. + /// Whether to refresh the library. + /// Folder removed. + /// A . + [HttpDelete] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task RemoveVirtualFolder( + [FromQuery] string? name, + [FromQuery] bool refreshLibrary = false) + { + await _libraryManager.RemoveVirtualFolder(name, refreshLibrary).ConfigureAwait(false); + return NoContent(); + } + + /// + /// Renames a virtual folder. + /// + /// The name of the virtual folder. + /// The new name. + /// Whether to refresh the library. + /// Folder renamed. + /// Library doesn't exist. + /// Library already exists. + /// A on success, a if the library doesn't exist, a if the new name is already taken. + /// The new name may not be null. + [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)) { - return _libraryManager.GetVirtualFolders(true); + throw new ArgumentNullException(nameof(name)); } - /// - /// Adds a virtual folder. - /// - /// The name of the virtual folder. - /// The type of the collection. - /// The paths of the virtual folder. - /// The library options. - /// Whether to refresh the library. - /// Folder added. - /// A . - [HttpPost] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task AddVirtualFolder( - [FromQuery] string? name, - [FromQuery] CollectionTypeOptions? collectionType, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] paths, - [FromBody] AddVirtualFolderDto? libraryOptionsDto, - [FromQuery] bool refreshLibrary = false) + if (string.IsNullOrWhiteSpace(newName)) { - var libraryOptions = libraryOptionsDto?.LibraryOptions ?? new LibraryOptions(); - - if (paths is not null && paths.Length > 0) - { - libraryOptions.PathInfos = paths.Select(i => new MediaPathInfo(i)).ToArray(); - } - - await _libraryManager.AddVirtualFolder(name, collectionType, libraryOptions, refreshLibrary).ConfigureAwait(false); - - return NoContent(); + throw new ArgumentNullException(nameof(newName)); } - /// - /// Removes a virtual folder. - /// - /// The name of the folder. - /// Whether to refresh the library. - /// Folder removed. - /// A . - [HttpDelete] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task RemoveVirtualFolder( - [FromQuery] string? name, - [FromQuery] bool refreshLibrary = false) + var rootFolderPath = _appPaths.DefaultUserViewsPath; + + var currentPath = Path.Combine(rootFolderPath, name); + var newPath = Path.Combine(rootFolderPath, newName); + + if (!Directory.Exists(currentPath)) { - await _libraryManager.RemoveVirtualFolder(name, refreshLibrary).ConfigureAwait(false); - return NoContent(); + return NotFound("The media collection does not exist."); } - /// - /// Renames a virtual folder. - /// - /// The name of the virtual folder. - /// The new name. - /// Whether to refresh the library. - /// Folder renamed. - /// Library doesn't exist. - /// Library already exists. - /// A on success, a if the library doesn't exist, a if the new name is already taken. - /// The new name may not be null. - [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.Equals(currentPath, newPath, StringComparison.OrdinalIgnoreCase) && Directory.Exists(newPath)) { - if (string.IsNullOrWhiteSpace(name)) + return Conflict($"The media library already exists at {newPath}."); + } + + _libraryMonitor.Stop(); + + try + { + // Changing capitalization. Handle windows case insensitivity + if (string.Equals(currentPath, newPath, StringComparison.OrdinalIgnoreCase)) { - throw new ArgumentNullException(nameof(name)); + var tempPath = Path.Combine( + rootFolderPath, + Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)); + Directory.Move(currentPath, tempPath); + currentPath = tempPath; } - if (string.IsNullOrWhiteSpace(newName)) + Directory.Move(currentPath, newPath); + } + finally + { + CollectionFolder.OnCollectionFolderChange(); + + Task.Run(async () => { - throw new ArgumentNullException(nameof(newName)); - } - - var rootFolderPath = _appPaths.DefaultUserViewsPath; - - var currentPath = Path.Combine(rootFolderPath, name); - var newPath = Path.Combine(rootFolderPath, newName); - - if (!Directory.Exists(currentPath)) - { - return NotFound("The media collection does not exist."); - } - - if (!string.Equals(currentPath, newPath, StringComparison.OrdinalIgnoreCase) && Directory.Exists(newPath)) - { - return Conflict($"The media library already exists at {newPath}."); - } - - _libraryMonitor.Stop(); - - try - { - // 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(), 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(), 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(); + } + }); } - /// - /// Add a media path to a library. - /// - /// The media path dto. - /// Whether to refresh the library. - /// A . - /// Media path added. - /// The name of the library may not be empty. - [HttpPost("Paths")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult AddMediaPath( - [FromBody, Required] MediaPathDto mediaPathDto, - [FromQuery] bool refreshLibrary = false) + return NoContent(); + } + + /// + /// Add a media path to a library. + /// + /// The media path dto. + /// Whether to refresh the library. + /// A . + /// Media path added. + /// The name of the library may not be empty. + [HttpPost("Paths")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult AddMediaPath( + [FromBody, Required] MediaPathDto mediaPathDto, + [FromQuery] bool refreshLibrary = false) + { + _libraryMonitor.Stop(); + + try { - _libraryMonitor.Stop(); + var mediaPath = mediaPathDto.PathInfo ?? new MediaPathInfo(mediaPathDto.Path ?? throw new ArgumentException("PathInfo and Path can't both be null.")); - try + _libraryManager.AddMediaPath(mediaPathDto.Name, mediaPath); + } + finally + { + Task.Run(async () => { - var mediaPath = mediaPathDto.PathInfo ?? new MediaPathInfo(mediaPathDto.Path ?? throw new ArgumentException("PathInfo and Path can't both be null.")); - - _libraryManager.AddMediaPath(mediaPathDto.Name, mediaPath); - } - finally - { - 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(), 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(); - } - - /// - /// Updates a media path. - /// - /// The name of the library and path infos. - /// A . - /// Media path updated. - /// The name of the library may not be empty. - [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"); - } - - _libraryManager.UpdateMediaPath(mediaPathRequestDto.Name, mediaPathRequestDto.PathInfo); - return NoContent(); - } - - /// - /// Remove a media path. - /// - /// The name of the library. - /// The path to remove. - /// Whether to refresh the library. - /// A . - /// Media path removed. - /// The name of the library may not be empty. - [HttpDelete("Paths")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult RemoveMediaPath( - [FromQuery] string? name, - [FromQuery] string? path, - [FromQuery] bool refreshLibrary = false) - { - if (string.IsNullOrWhiteSpace(name)) - { - throw new ArgumentNullException(nameof(name)); - } - - _libraryMonitor.Stop(); - - try - { - _libraryManager.RemoveMediaPath(name, path); - } - finally - { - Task.Run(async () => + await _libraryManager.ValidateMediaLibrary(new SimpleProgress(), CancellationToken.None).ConfigureAwait(false); + } + else { - // No need to start if scanning the library because it will handle it - if (refreshLibrary) - { - await _libraryManager.ValidateMediaLibrary(new SimpleProgress(), 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(); + } + }); } - /// - /// Update library options. - /// - /// The library name and options. - /// Library updated. - /// A . - [HttpPost("LibraryOptions")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult UpdateLibraryOptions( - [FromBody] UpdateLibraryOptionsDto request) + return NoContent(); + } + + /// + /// Updates a media path. + /// + /// The name of the library and path infos. + /// A . + /// Media path updated. + /// The name of the library may not be empty. + [HttpPost("Paths/Update")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult UpdateMediaPath([FromBody, Required] UpdateMediaPathRequestDto mediaPathRequestDto) + { + if (string.IsNullOrWhiteSpace(mediaPathRequestDto.Name)) { - var collectionFolder = (CollectionFolder)_libraryManager.GetItemById(request.Id); - - collectionFolder.UpdateLibraryOptions(request.LibraryOptions); - return NoContent(); + throw new ArgumentNullException(nameof(mediaPathRequestDto), "Name must not be null or empty"); } + + _libraryManager.UpdateMediaPath(mediaPathRequestDto.Name, mediaPathRequestDto.PathInfo); + return NoContent(); + } + + /// + /// Remove a media path. + /// + /// The name of the library. + /// The path to remove. + /// Whether to refresh the library. + /// A . + /// Media path removed. + /// The name of the library may not be empty. + [HttpDelete("Paths")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult RemoveMediaPath( + [FromQuery] string? name, + [FromQuery] string? path, + [FromQuery] bool refreshLibrary = false) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentNullException(nameof(name)); + } + + _libraryMonitor.Stop(); + + try + { + _libraryManager.RemoveMediaPath(name, path); + } + finally + { + Task.Run(async () => + { + // No need to start if scanning the library because it will handle it + if (refreshLibrary) + { + await _libraryManager.ValidateMediaLibrary(new SimpleProgress(), 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(); + } + + /// + /// Update library options. + /// + /// The library name and options. + /// Library updated. + /// A . + [HttpPost("LibraryOptions")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult UpdateLibraryOptions( + [FromBody] UpdateLibraryOptionsDto request) + { + var collectionFolder = (CollectionFolder)_libraryManager.GetItemById(request.Id); + + collectionFolder.UpdateLibraryOptions(request.LibraryOptions); + return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs index 5228e0babf..267ba4afb4 100644 --- a/Jellyfin.Api/Controllers/LiveTvController.cs +++ b/Jellyfin.Api/Controllers/LiveTvController.cs @@ -17,14 +17,12 @@ using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.LiveTvDtos; using Jellyfin.Data.Enums; using MediaBrowser.Common.Configuration; -using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Session; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; @@ -35,1200 +33,1176 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// Live tv controller. +/// +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; + /// - /// Live tv controller. + /// Initializes a new instance of the class. /// - public class LiveTvController : BaseJellyfinApiController + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the class. + /// Instance of the interface. + 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; + _liveTvManager = liveTvManager; + _userManager = userManager; + _httpClientFactory = httpClientFactory; + _libraryManager = libraryManager; + _dtoService = dtoService; + _mediaSourceManager = mediaSourceManager; + _configurationManager = configurationManager; + _transcodingJobHelper = transcodingJobHelper; + _sessionManager = sessionManager; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the class. - /// Instance of the interface. - 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; - } + /// + /// Gets available live tv services. + /// + /// Available live tv services returned. + /// + /// An containing the available live tv services. + /// + [HttpGet("Info")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.LiveTvAccess)] + public ActionResult GetLiveTvInfo() + { + return _liveTvManager.GetLiveTvInfo(CancellationToken.None); + } - /// - /// Gets available live tv services. - /// - /// Available live tv services returned. - /// - /// An containing the available live tv services. - /// - [HttpGet("Info")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.DefaultAuthorization)] - public ActionResult GetLiveTvInfo() - { - return _liveTvManager.GetLiveTvInfo(CancellationToken.None); - } + /// + /// Gets available live tv channels. + /// + /// Optional. Filter by channel type. + /// Optional. Filter by user and attach user data. + /// Optional. The record index to start at. All items with a lower index will be dropped from the results. + /// Optional. Filter for movies. + /// Optional. Filter for series. + /// Optional. Filter for news. + /// Optional. Filter for kids. + /// Optional. Filter for sports. + /// Optional. The maximum number of records to return. + /// Optional. Filter by channels that are favorites, or not. + /// Optional. Filter by channels that are liked, or not. + /// Optional. Filter by channels that are disliked, or not. + /// Optional. Include image information in output. + /// Optional. The max number of images to return, per image type. + /// "Optional. The image types to include in the output. + /// Optional. Specify additional fields of information to return in the output. + /// Optional. Include user data. + /// Optional. Key to sort by. + /// Optional. Sort order. + /// Optional. Incorporate favorite and like status into channel sorting. + /// Optional. Adds current program info to each channel. + /// Available live tv channels returned. + /// + /// An containing the resulting available live tv channels. + /// + [HttpGet("Channels")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.LiveTvAccess)] + public ActionResult> 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); - /// - /// Gets available live tv channels. - /// - /// Optional. Filter by channel type. - /// Optional. Filter by user and attach user data. - /// Optional. The record index to start at. All items with a lower index will be dropped from the results. - /// Optional. Filter for movies. - /// Optional. Filter for series. - /// Optional. Filter for news. - /// Optional. Filter for kids. - /// Optional. Filter for sports. - /// Optional. The maximum number of records to return. - /// Optional. Filter by channels that are favorites, or not. - /// Optional. Filter by channels that are liked, or not. - /// Optional. Filter by channels that are disliked, or not. - /// Optional. Include image information in output. - /// Optional. The max number of images to return, per image type. - /// "Optional. The image types to include in the output. - /// Optional. Specify additional fields of information to return in the output. - /// Optional. Include user data. - /// Optional. Key to sort by. - /// Optional. Sort order. - /// Optional. Incorporate favorite and like status into channel sorting. - /// Optional. Adds current program info to each channel. - /// Available live tv channels returned. - /// - /// An containing the resulting available live tv channels. - /// - [HttpGet("Channels")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.DefaultAuthorization)] - public ActionResult> 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( - startIndex, - channelResult.TotalRecordCount, - returnArray); - } - - /// - /// Gets a live tv channel. - /// - /// Channel id. - /// Optional. Attach user data. - /// Live tv channel returned. - /// An containing the live tv channel. - [HttpGet("Channels/{channelId}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.DefaultAuthorization)] - public ActionResult 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); - } - - /// - /// Gets live tv recordings. - /// - /// Optional. Filter by channel id. - /// Optional. Filter by user and attach user data. - /// Optional. The record index to start at. All items with a lower index will be dropped from the results. - /// Optional. The maximum number of records to return. - /// Optional. Filter by recording status. - /// Optional. Filter by recordings that are in progress, or not. - /// Optional. Filter by recordings belonging to a series timer. - /// Optional. Include image information in output. - /// Optional. The max number of images to return, per image type. - /// Optional. The image types to include in the output. - /// Optional. Specify additional fields of information to return in the output. - /// Optional. Include user data. - /// Optional. Filter for movies. - /// Optional. Filter for series. - /// Optional. Filter for kids. - /// Optional. Filter for sports. - /// Optional. Filter for news. - /// Optional. Filter for is library item. - /// Optional. Return total record count. - /// Live tv recordings returned. - /// An containing the live tv recordings. - [HttpGet("Recordings")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.DefaultAuthorization)] - public ActionResult> 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); - } - - /// - /// Gets live tv recording series. - /// - /// Optional. Filter by channel id. - /// Optional. Filter by user and attach user data. - /// Optional. Filter by recording group. - /// Optional. The record index to start at. All items with a lower index will be dropped from the results. - /// Optional. The maximum number of records to return. - /// Optional. Filter by recording status. - /// Optional. Filter by recordings that are in progress, or not. - /// Optional. Filter by recordings belonging to a series timer. - /// Optional. Include image information in output. - /// Optional. The max number of images to return, per image type. - /// Optional. The image types to include in the output. - /// Optional. Specify additional fields of information to return in the output. - /// Optional. Include user data. - /// Optional. Return total record count. - /// Live tv recordings returned. - /// An containing the live tv recordings. - [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> 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(); - } - - /// - /// Gets live tv recording groups. - /// - /// Optional. Filter by user and attach user data. - /// Recording groups returned. - /// An containing the recording groups. - [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> GetRecordingGroups([FromQuery] Guid? userId) - { - return new QueryResult(); - } - - /// - /// Gets recording folders. - /// - /// Optional. Filter by user and attach user data. - /// Recording folders returned. - /// An containing the recording folders. - [HttpGet("Recordings/Folders")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.DefaultAuthorization)] - public ActionResult> GetRecordingFolders([FromQuery] Guid? userId) - { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - var folders = _liveTvManager.GetRecordingFolders(user); - - var returnArray = _dtoService.GetBaseItemDtos(folders, new DtoOptions(), user); - - return new QueryResult(returnArray); - } - - /// - /// Gets a live tv recording. - /// - /// Recording id. - /// Optional. Attach user data. - /// Recording returned. - /// An containing the live tv recording. - [HttpGet("Recordings/{recordingId}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.DefaultAuthorization)] - public ActionResult 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 dtoOptions = new DtoOptions() - .AddClientFields(User); - - return _dtoService.GetBaseItemDto(item, dtoOptions, user); - } - - /// - /// Resets a tv tuner. - /// - /// Tuner id. - /// Tuner reset. - /// A . - [HttpPost("Tuners/{tunerId}/Reset")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.DefaultAuthorization)] - public async Task ResetTuner([FromRoute, Required] string tunerId) - { - await AssertUserCanManageLiveTv().ConfigureAwait(false); - await _liveTvManager.ResetTuner(tunerId, CancellationToken.None).ConfigureAwait(false); - return NoContent(); - } - - /// - /// Gets a timer. - /// - /// Timer id. - /// Timer returned. - /// - /// A containing an which contains the timer. - /// - [HttpGet("Timers/{timerId}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.DefaultAuthorization)] - public async Task> GetTimer([FromRoute, Required] string timerId) - { - return await _liveTvManager.GetTimer(timerId, CancellationToken.None).ConfigureAwait(false); - } - - /// - /// Gets the default values for a new timer. - /// - /// Optional. To attach default values based on a program. - /// Default values returned. - /// - /// A containing an which contains the default values for a timer. - /// - [HttpGet("Timers/Defaults")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.DefaultAuthorization)] - public async Task> GetDefaultTimer([FromQuery] string? programId) - { - return string.IsNullOrEmpty(programId) - ? await _liveTvManager.GetNewTimerDefaults(CancellationToken.None).ConfigureAwait(false) - : await _liveTvManager.GetNewTimerDefaults(programId, CancellationToken.None).ConfigureAwait(false); - } - - /// - /// Gets the live tv timers. - /// - /// Optional. Filter by channel id. - /// Optional. Filter by timers belonging to a series timer. - /// Optional. Filter by timers that are active. - /// Optional. Filter by timers that are scheduled. - /// - /// A containing an which contains the live tv timers. - /// - [HttpGet("Timers")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.DefaultAuthorization)] - public async Task>> 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); - } - - /// - /// Gets available live tv epgs. - /// - /// The channels to return guide information for. - /// Optional. Filter by user id. - /// Optional. The minimum premiere start date. - /// Optional. Filter by programs that have completed airing, or not. - /// Optional. Filter by programs that are currently airing, or not. - /// Optional. The maximum premiere start date. - /// Optional. The minimum premiere end date. - /// Optional. The maximum premiere end date. - /// Optional. Filter for movies. - /// Optional. Filter for series. - /// Optional. Filter for news. - /// Optional. Filter for kids. - /// Optional. Filter for sports. - /// Optional. The record index to start at. All items with a lower index will be dropped from the results. - /// Optional. The maximum number of records to return. - /// Optional. Specify one or more sort orders, comma delimited. Options: Name, StartDate. - /// Sort Order - Ascending,Descending. - /// The genres to return guide information for. - /// The genre ids to return guide information for. - /// Optional. Include image information in output. - /// Optional. The max number of images to return, per image type. - /// Optional. The image types to include in the output. - /// Optional. Include user data. - /// Optional. Filter by series timer id. - /// Optional. Filter by library series id. - /// Optional. Specify additional fields of information to return in the output. - /// Retrieve total record count. - /// Live tv epgs returned. - /// - /// A containing a which contains the live tv epgs. - /// - [HttpGet("Programs")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.DefaultAuthorization)] - public async Task>> 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); - - var query = new InternalItemsQuery(user) + var channelResult = _liveTvManager.GetInternalChannels( + new LiveTvChannelQuery { - ChannelIds = channelIds, - HasAired = hasAired, - IsAiring = isAiring, - EnableTotalRecordCount = enableTotalRecordCount, - MinStartDate = minStartDate, - MinEndDate = minEndDate, - MaxStartDate = maxStartDate, - MaxEndDate = maxEndDate, + ChannelType = type, + UserId = userId.Value, StartIndex = startIndex, Limit = limit, - OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder), - IsNews = isNews, + IsFavorite = isFavorite, + IsLiked = isLiked, + IsDisliked = isDisliked, + EnableFavoriteSorting = enableFavoriteSorting, IsMovie = isMovie, IsSeries = isSeries, + IsNews = isNews, IsKids = isKids, IsSports = isSports, - SeriesTimerId = seriesTimerId, - Genres = genres, - GenreIds = genreIds - }; + SortBy = sortBy, + SortOrder = sortOrder ?? SortOrder.Ascending, + AddCurrentProgram = addCurrentProgram + }, + dtoOptions, + CancellationToken.None); - if (librarySeriesId.HasValue && !librarySeriesId.Equals(default)) + 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( + startIndex, + channelResult.TotalRecordCount, + returnArray); + } + + /// + /// Gets a live tv channel. + /// + /// Channel id. + /// Optional. Attach user data. + /// Live tv channel returned. + /// An containing the live tv channel. + [HttpGet("Channels/{channelId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.LiveTvAccess)] + public ActionResult 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); + } + + /// + /// Gets live tv recordings. + /// + /// Optional. Filter by channel id. + /// Optional. Filter by user and attach user data. + /// Optional. The record index to start at. All items with a lower index will be dropped from the results. + /// Optional. The maximum number of records to return. + /// Optional. Filter by recording status. + /// Optional. Filter by recordings that are in progress, or not. + /// Optional. Filter by recordings belonging to a series timer. + /// Optional. Include image information in output. + /// Optional. The max number of images to return, per image type. + /// Optional. The image types to include in the output. + /// Optional. Specify additional fields of information to return in the output. + /// Optional. Include user data. + /// Optional. Filter for movies. + /// Optional. Filter for series. + /// Optional. Filter for kids. + /// Optional. Filter for sports. + /// Optional. Filter for news. + /// Optional. Filter for is library item. + /// Optional. Return total record count. + /// Live tv recordings returned. + /// An containing the live tv recordings. + [HttpGet("Recordings")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.LiveTvAccess)] + public async Task>> 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); + + return await _liveTvManager.GetRecordingsAsync( + new RecordingQuery { - query.IsSeries = true; - - if (_libraryManager.GetItemById(librarySeriesId.Value) is Series series) - { - query.Name = series.Name; - } - } - - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - return await _liveTvManager.GetPrograms(query, dtoOptions, CancellationToken.None).ConfigureAwait(false); - } - - /// - /// Gets available live tv epgs. - /// - /// Request body. - /// Live tv epgs returned. - /// - /// A containing a which contains the live tv epgs. - /// - [HttpPost("Programs")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.DefaultAuthorization)] - public async Task>> GetPrograms([FromBody] GetProgramsDto body) - { - var user = body.UserId.Equals(default) ? null : _userManager.GetUserById(body.UserId); - - 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)) - { - query.IsSeries = true; - - if (_libraryManager.GetItemById(body.LibrarySeriesId) is Series series) - { - 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); - } - - /// - /// Gets recommended live tv epgs. - /// - /// Optional. filter by user id. - /// Optional. The maximum number of records to return. - /// Optional. Filter by programs that are currently airing, or not. - /// Optional. Filter by programs that have completed airing, or not. - /// Optional. Filter for series. - /// Optional. Filter for movies. - /// Optional. Filter for news. - /// Optional. Filter for kids. - /// Optional. Filter for sports. - /// Optional. Include image information in output. - /// Optional. The max number of images to return, per image type. - /// Optional. The image types to include in the output. - /// The genres to return guide information for. - /// Optional. Specify additional fields of information to return in the output. - /// Optional. include user data. - /// Retrieve total record count. - /// Recommended epgs returned. - /// A containing the queryresult of recommended epgs. - [HttpGet("Programs/Recommended")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task>> 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, + ChannelId = channelId, + UserId = userId.Value, + StartIndex = startIndex, Limit = limit, - HasAired = hasAired, - IsSeries = isSeries, - IsMovie = isMovie, - IsKids = isKids, - IsNews = isNews, - IsSports = isSports, + Status = status, + SeriesTimerId = seriesTimerId, + IsInProgress = isInProgress, EnableTotalRecordCount = enableTotalRecordCount, - GenreIds = genreIds - }; + IsMovie = isMovie, + IsNews = isNews, + IsSeries = isSeries, + IsKids = isKids, + IsSports = isSports, + IsLibraryItem = isLibraryItem, + Fields = fields, + ImageTypeLimit = imageTypeLimit, + EnableImages = enableImages + }, + dtoOptions).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); - } + /// + /// Gets live tv recording series. + /// + /// Optional. Filter by channel id. + /// Optional. Filter by user and attach user data. + /// Optional. Filter by recording group. + /// Optional. The record index to start at. All items with a lower index will be dropped from the results. + /// Optional. The maximum number of records to return. + /// Optional. Filter by recording status. + /// Optional. Filter by recordings that are in progress, or not. + /// Optional. Filter by recordings belonging to a series timer. + /// Optional. Include image information in output. + /// Optional. The max number of images to return, per image type. + /// Optional. The image types to include in the output. + /// Optional. Specify additional fields of information to return in the output. + /// Optional. Include user data. + /// Optional. Return total record count. + /// Live tv recordings returned. + /// An containing the live tv recordings. + [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> 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(); + } - /// - /// Gets a live tv program. - /// - /// Program id. - /// Optional. Attach user data. - /// Program returned. - /// An containing the livetv program. - [HttpGet("Programs/{programId}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> GetProgram( - [FromRoute, Required] string programId, - [FromQuery] Guid? userId) - { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); + /// + /// Gets live tv recording groups. + /// + /// Optional. Filter by user and attach user data. + /// Recording groups returned. + /// An containing the recording groups. + [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> GetRecordingGroups([FromQuery] Guid? userId) + { + return new QueryResult(); + } - return await _liveTvManager.GetProgram(programId, CancellationToken.None, user).ConfigureAwait(false); - } + /// + /// Gets recording folders. + /// + /// Optional. Filter by user and attach user data. + /// Recording folders returned. + /// An containing the recording folders. + [HttpGet("Recordings/Folders")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.LiveTvAccess)] + public async Task>> 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); - /// - /// Deletes a live tv recording. - /// - /// Recording id. - /// Recording deleted. - /// Item not found. - /// A on success, or a if item not found. - [HttpDelete("Recordings/{recordingId}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task DeleteRecording([FromRoute, Required] Guid recordingId) - { - await AssertUserCanManageLiveTv().ConfigureAwait(false); + var returnArray = _dtoService.GetBaseItemDtos(folders, new DtoOptions(), user); - var item = _libraryManager.GetItemById(recordingId); - if (item is null) + return new QueryResult(returnArray); + } + + /// + /// Gets a live tv recording. + /// + /// Recording id. + /// Optional. Attach user data. + /// Recording returned. + /// An containing the live tv recording. + [HttpGet("Recordings/{recordingId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.LiveTvAccess)] + public ActionResult 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); + + var dtoOptions = new DtoOptions() + .AddClientFields(User); + + return _dtoService.GetBaseItemDto(item, dtoOptions, user); + } + + /// + /// Resets a tv tuner. + /// + /// Tuner id. + /// Tuner reset. + /// A . + [HttpPost("Tuners/{tunerId}/Reset")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.LiveTvManagement)] + public async Task ResetTuner([FromRoute, Required] string tunerId) + { + await _liveTvManager.ResetTuner(tunerId, CancellationToken.None).ConfigureAwait(false); + return NoContent(); + } + + /// + /// Gets a timer. + /// + /// Timer id. + /// Timer returned. + /// + /// A containing an which contains the timer. + /// + [HttpGet("Timers/{timerId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.LiveTvAccess)] + public async Task> GetTimer([FromRoute, Required] string timerId) + { + return await _liveTvManager.GetTimer(timerId, CancellationToken.None).ConfigureAwait(false); + } + + /// + /// Gets the default values for a new timer. + /// + /// Optional. To attach default values based on a program. + /// Default values returned. + /// + /// A containing an which contains the default values for a timer. + /// + [HttpGet("Timers/Defaults")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.LiveTvAccess)] + public async Task> GetDefaultTimer([FromQuery] string? programId) + { + return string.IsNullOrEmpty(programId) + ? await _liveTvManager.GetNewTimerDefaults(CancellationToken.None).ConfigureAwait(false) + : await _liveTvManager.GetNewTimerDefaults(programId, CancellationToken.None).ConfigureAwait(false); + } + + /// + /// Gets the live tv timers. + /// + /// Optional. Filter by channel id. + /// Optional. Filter by timers belonging to a series timer. + /// Optional. Filter by timers that are active. + /// Optional. Filter by timers that are scheduled. + /// + /// A containing an which contains the live tv timers. + /// + [HttpGet("Timers")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.LiveTvAccess)] + public async Task>> GetTimers( + [FromQuery] string? channelId, + [FromQuery] string? seriesTimerId, + [FromQuery] bool? isActive, + [FromQuery] bool? isScheduled) + { + return await _liveTvManager.GetTimers( + new TimerQuery { - return NotFound(); + ChannelId = channelId, + SeriesTimerId = seriesTimerId, + IsActive = isActive, + IsScheduled = isScheduled + }, + CancellationToken.None).ConfigureAwait(false); + } + + /// + /// Gets available live tv epgs. + /// + /// The channels to return guide information for. + /// Optional. Filter by user id. + /// Optional. The minimum premiere start date. + /// Optional. Filter by programs that have completed airing, or not. + /// Optional. Filter by programs that are currently airing, or not. + /// Optional. The maximum premiere start date. + /// Optional. The minimum premiere end date. + /// Optional. The maximum premiere end date. + /// Optional. Filter for movies. + /// Optional. Filter for series. + /// Optional. Filter for news. + /// Optional. Filter for kids. + /// Optional. Filter for sports. + /// Optional. The record index to start at. All items with a lower index will be dropped from the results. + /// Optional. The maximum number of records to return. + /// Optional. Specify one or more sort orders, comma delimited. Options: Name, StartDate. + /// Sort Order - Ascending,Descending. + /// The genres to return guide information for. + /// The genre ids to return guide information for. + /// Optional. Include image information in output. + /// Optional. The max number of images to return, per image type. + /// Optional. The image types to include in the output. + /// Optional. Include user data. + /// Optional. Filter by series timer id. + /// Optional. Filter by library series id. + /// Optional. Specify additional fields of information to return in the output. + /// Retrieve total record count. + /// Live tv epgs returned. + /// + /// A containing a which contains the live tv epgs. + /// + [HttpGet("Programs")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.LiveTvAccess)] + public async Task>> 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); + + var query = new InternalItemsQuery(user) + { + 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; + + if (_libraryManager.GetItemById(librarySeriesId.Value) is Series series) + { + query.Name = series.Name; } + } - _libraryManager.DeleteItem(item, new DeleteOptions + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + return await _liveTvManager.GetPrograms(query, dtoOptions, CancellationToken.None).ConfigureAwait(false); + } + + /// + /// Gets available live tv epgs. + /// + /// Request body. + /// Live tv epgs returned. + /// + /// A containing a which contains the live tv epgs. + /// + [HttpPost("Programs")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.LiveTvAccess)] + public async Task>> GetPrograms([FromBody] GetProgramsDto body) + { + var user = body.UserId.Equals(default) ? null : _userManager.GetUserById(body.UserId); + + 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)) + { + query.IsSeries = true; + + if (_libraryManager.GetItemById(body.LibrarySeriesId) is Series series) { - DeleteFileLocation = false - }); - - return NoContent(); - } - - /// - /// Cancels a live tv timer. - /// - /// Timer id. - /// Timer deleted. - /// A . - [HttpDelete("Timers/{timerId}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task CancelTimer([FromRoute, Required] string timerId) - { - await AssertUserCanManageLiveTv().ConfigureAwait(false); - await _liveTvManager.CancelTimer(timerId).ConfigureAwait(false); - return NoContent(); - } - - /// - /// Updates a live tv timer. - /// - /// Timer id. - /// New timer info. - /// Timer updated. - /// A . - [HttpPost("Timers/{timerId}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "timerId", Justification = "Imported from ServiceStack")] - public async Task UpdateTimer([FromRoute, Required] string timerId, [FromBody] TimerInfoDto timerInfo) - { - await AssertUserCanManageLiveTv().ConfigureAwait(false); - await _liveTvManager.UpdateTimer(timerInfo, CancellationToken.None).ConfigureAwait(false); - return NoContent(); - } - - /// - /// Creates a live tv timer. - /// - /// New timer info. - /// Timer created. - /// A . - [HttpPost("Timers")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task CreateTimer([FromBody] TimerInfoDto timerInfo) - { - await AssertUserCanManageLiveTv().ConfigureAwait(false); - await _liveTvManager.CreateTimer(timerInfo, CancellationToken.None).ConfigureAwait(false); - return NoContent(); - } - - /// - /// Gets a live tv series timer. - /// - /// Timer id. - /// Series timer returned. - /// Series timer not found. - /// A on success, or a if timer not found. - [HttpGet("SeriesTimers/{timerId}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> GetSeriesTimer([FromRoute, Required] string timerId) - { - var timer = await _liveTvManager.GetSeriesTimer(timerId, CancellationToken.None).ConfigureAwait(false); - if (timer is null) - { - return NotFound(); + query.Name = series.Name; } - - return timer; } - /// - /// Gets live tv series timers. - /// - /// Optional. Sort by SortName or Priority. - /// Optional. Sort in Ascending or Descending order. - /// Timers returned. - /// An of live tv series timers. - [HttpGet("SeriesTimers")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task>> GetSeriesTimers([FromQuery] string? sortBy, [FromQuery] SortOrder? sortOrder) + 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); + } + + /// + /// Gets recommended live tv epgs. + /// + /// Optional. filter by user id. + /// Optional. The maximum number of records to return. + /// Optional. Filter by programs that are currently airing, or not. + /// Optional. Filter by programs that have completed airing, or not. + /// Optional. Filter for series. + /// Optional. Filter for movies. + /// Optional. Filter for news. + /// Optional. Filter for kids. + /// Optional. Filter for sports. + /// Optional. Include image information in output. + /// Optional. The max number of images to return, per image type. + /// Optional. The image types to include in the output. + /// The genres to return guide information for. + /// Optional. Specify additional fields of information to return in the output. + /// Optional. include user data. + /// Retrieve total record count. + /// Recommended epgs returned. + /// A containing the queryresult of recommended epgs. + [HttpGet("Programs/Recommended")] + [Authorize(Policy = Policies.LiveTvAccess)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task>> 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); + + var query = new InternalItemsQuery(user) { - return await _liveTvManager.GetSeriesTimers( - new SeriesTimerQuery - { - SortOrder = sortOrder ?? SortOrder.Ascending, - SortBy = sortBy - }, - CancellationToken.None).ConfigureAwait(false); - } + IsAiring = isAiring, + Limit = limit, + HasAired = hasAired, + IsSeries = isSeries, + IsMovie = isMovie, + IsKids = isKids, + IsNews = isNews, + IsSports = isSports, + EnableTotalRecordCount = enableTotalRecordCount, + GenreIds = genreIds + }; - /// - /// Cancels a live tv series timer. - /// - /// Timer id. - /// Timer cancelled. - /// A . - [HttpDelete("SeriesTimers/{timerId}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task CancelSeriesTimer([FromRoute, Required] string timerId) - { - await AssertUserCanManageLiveTv().ConfigureAwait(false); - await _liveTvManager.CancelSeriesTimer(timerId).ConfigureAwait(false); - return NoContent(); - } + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + return await _liveTvManager.GetRecommendedProgramsAsync(query, dtoOptions, CancellationToken.None).ConfigureAwait(false); + } - /// - /// Updates a live tv series timer. - /// - /// Timer id. - /// New series timer info. - /// Series timer updated. - /// A . - [HttpPost("SeriesTimers/{timerId}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "timerId", Justification = "Imported from ServiceStack")] - public async Task UpdateSeriesTimer([FromRoute, Required] string timerId, [FromBody] SeriesTimerInfoDto seriesTimerInfo) - { - await AssertUserCanManageLiveTv().ConfigureAwait(false); - await _liveTvManager.UpdateSeriesTimer(seriesTimerInfo, CancellationToken.None).ConfigureAwait(false); - return NoContent(); - } + /// + /// Gets a live tv program. + /// + /// Program id. + /// Optional. Attach user data. + /// Program returned. + /// An containing the livetv program. + [HttpGet("Programs/{programId}")] + [Authorize(Policy = Policies.LiveTvAccess)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetProgram( + [FromRoute, Required] string programId, + [FromQuery] Guid? userId) + { + userId = RequestHelpers.GetUserId(User, userId); + var user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); - /// - /// Creates a live tv series timer. - /// - /// New series timer info. - /// Series timer info created. - /// A . - [HttpPost("SeriesTimers")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task CreateSeriesTimer([FromBody] SeriesTimerInfoDto seriesTimerInfo) - { - await AssertUserCanManageLiveTv().ConfigureAwait(false); - await _liveTvManager.CreateSeriesTimer(seriesTimerInfo, CancellationToken.None).ConfigureAwait(false); - return NoContent(); - } + return await _liveTvManager.GetProgram(programId, CancellationToken.None, user).ConfigureAwait(false); + } - /// - /// Get recording group. - /// - /// Group id. - /// A . - [HttpGet("Recordings/Groups/{groupId}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [Obsolete("This endpoint is obsolete.")] - public ActionResult GetRecordingGroup([FromRoute, Required] Guid groupId) + /// + /// Deletes a live tv recording. + /// + /// Recording id. + /// Recording deleted. + /// Item not found. + /// A on success, or a if item not found. + [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 NotFound(); } - /// - /// Get guid info. - /// - /// Guid info returned. - /// An containing the guide info. - [HttpGet("GuideInfo")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult GetGuideInfo() + _libraryManager.DeleteItem(item, new DeleteOptions { - return _liveTvManager.GetGuideInfo(); + DeleteFileLocation = false + }); + + return NoContent(); + } + + /// + /// Cancels a live tv timer. + /// + /// Timer id. + /// Timer deleted. + /// A . + [HttpDelete("Timers/{timerId}")] + [Authorize(Policy = Policies.LiveTvManagement)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task CancelTimer([FromRoute, Required] string timerId) + { + await _liveTvManager.CancelTimer(timerId).ConfigureAwait(false); + return NoContent(); + } + + /// + /// Updates a live tv timer. + /// + /// Timer id. + /// New timer info. + /// Timer updated. + /// A . + [HttpPost("Timers/{timerId}")] + [Authorize(Policy = Policies.LiveTvManagement)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "timerId", Justification = "Imported from ServiceStack")] + public async Task UpdateTimer([FromRoute, Required] string timerId, [FromBody] TimerInfoDto timerInfo) + { + await _liveTvManager.UpdateTimer(timerInfo, CancellationToken.None).ConfigureAwait(false); + return NoContent(); + } + + /// + /// Creates a live tv timer. + /// + /// New timer info. + /// Timer created. + /// A . + [HttpPost("Timers")] + [Authorize(Policy = Policies.LiveTvManagement)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task CreateTimer([FromBody] TimerInfoDto timerInfo) + { + await _liveTvManager.CreateTimer(timerInfo, CancellationToken.None).ConfigureAwait(false); + return NoContent(); + } + + /// + /// Gets a live tv series timer. + /// + /// Timer id. + /// Series timer returned. + /// Series timer not found. + /// A on success, or a if timer not found. + [HttpGet("SeriesTimers/{timerId}")] + [Authorize(Policy = Policies.LiveTvAccess)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetSeriesTimer([FromRoute, Required] string timerId) + { + var timer = await _liveTvManager.GetSeriesTimer(timerId, CancellationToken.None).ConfigureAwait(false); + if (timer is null) + { + return NotFound(); } - /// - /// Adds a tuner host. - /// - /// New tuner host. - /// Created tuner host returned. - /// A containing the created tuner host. - [HttpPost("TunerHosts")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> AddTunerHost([FromBody] TunerHostInfo tunerHostInfo) - { - return await _liveTvManager.SaveTunerHost(tunerHostInfo).ConfigureAwait(false); - } + return timer; + } - /// - /// Deletes a tuner host. - /// - /// Tuner host id. - /// Tuner host deleted. - /// A . - [HttpDelete("TunerHosts")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult DeleteTunerHost([FromQuery] string? id) - { - var config = _configurationManager.GetConfiguration("livetv"); - config.TunerHosts = config.TunerHosts.Where(i => !string.Equals(id, i.Id, StringComparison.OrdinalIgnoreCase)).ToArray(); - _configurationManager.SaveConfiguration("livetv", config); - return NoContent(); - } - - /// - /// Gets default listings provider info. - /// - /// Default listings provider info returned. - /// An containing the default listings provider info. - [HttpGet("ListingProviders/Default")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult GetDefaultListingProvider() - { - return new ListingsProviderInfo(); - } - - /// - /// Adds a listings provider. - /// - /// Password. - /// New listings info. - /// Validate listings. - /// Validate login. - /// Created listings provider returned. - /// A containing the created listings provider. - [HttpPost("ListingProviders")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [SuppressMessage("Microsoft.Performance", "CA5350:RemoveSha1", MessageId = "AddListingProvider", Justification = "Imported from ServiceStack")] - public async Task> AddListingProvider( - [FromQuery] string? pw, - [FromBody] ListingsProviderInfo listingsProviderInfo, - [FromQuery] bool validateListings = false, - [FromQuery] bool validateLogin = false) - { - if (!string.IsNullOrEmpty(pw)) + /// + /// Gets live tv series timers. + /// + /// Optional. Sort by SortName or Priority. + /// Optional. Sort in Ascending or Descending order. + /// Timers returned. + /// An of live tv series timers. + [HttpGet("SeriesTimers")] + [Authorize(Policy = Policies.LiveTvAccess)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task>> GetSeriesTimers([FromQuery] string? sortBy, [FromQuery] SortOrder? sortOrder) + { + return await _liveTvManager.GetSeriesTimers( + new SeriesTimerQuery { - // 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(); - } + SortOrder = sortOrder ?? SortOrder.Ascending, + SortBy = sortBy + }, + CancellationToken.None).ConfigureAwait(false); + } - return await _liveTvManager.SaveListingProvider(listingsProviderInfo, validateLogin, validateListings).ConfigureAwait(false); + /// + /// Cancels a live tv series timer. + /// + /// Timer id. + /// Timer cancelled. + /// A . + [HttpDelete("SeriesTimers/{timerId}")] + [Authorize(Policy = Policies.LiveTvManagement)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task CancelSeriesTimer([FromRoute, Required] string timerId) + { + await _liveTvManager.CancelSeriesTimer(timerId).ConfigureAwait(false); + return NoContent(); + } + + /// + /// Updates a live tv series timer. + /// + /// Timer id. + /// New series timer info. + /// Series timer updated. + /// A . + [HttpPost("SeriesTimers/{timerId}")] + [Authorize(Policy = Policies.LiveTvManagement)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "timerId", Justification = "Imported from ServiceStack")] + public async Task UpdateSeriesTimer([FromRoute, Required] string timerId, [FromBody] SeriesTimerInfoDto seriesTimerInfo) + { + await _liveTvManager.UpdateSeriesTimer(seriesTimerInfo, CancellationToken.None).ConfigureAwait(false); + return NoContent(); + } + + /// + /// Creates a live tv series timer. + /// + /// New series timer info. + /// Series timer info created. + /// A . + [HttpPost("SeriesTimers")] + [Authorize(Policy = Policies.LiveTvManagement)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task CreateSeriesTimer([FromBody] SeriesTimerInfoDto seriesTimerInfo) + { + await _liveTvManager.CreateSeriesTimer(seriesTimerInfo, CancellationToken.None).ConfigureAwait(false); + return NoContent(); + } + + /// + /// Get recording group. + /// + /// Group id. + /// A . + [HttpGet("Recordings/Groups/{groupId}")] + [Authorize(Policy = Policies.LiveTvAccess)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Obsolete("This endpoint is obsolete.")] + public ActionResult GetRecordingGroup([FromRoute, Required] Guid groupId) + { + return NotFound(); + } + + /// + /// Get guid info. + /// + /// Guid info returned. + /// An containing the guide info. + [HttpGet("GuideInfo")] + [Authorize(Policy = Policies.LiveTvAccess)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult GetGuideInfo() + { + return _liveTvManager.GetGuideInfo(); + } + + /// + /// Adds a tuner host. + /// + /// New tuner host. + /// Created tuner host returned. + /// A containing the created tuner host. + [HttpPost("TunerHosts")] + [Authorize(Policy = Policies.LiveTvManagement)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> AddTunerHost([FromBody] TunerHostInfo tunerHostInfo) + { + return await _liveTvManager.SaveTunerHost(tunerHostInfo).ConfigureAwait(false); + } + + /// + /// Deletes a tuner host. + /// + /// Tuner host id. + /// Tuner host deleted. + /// A . + [HttpDelete("TunerHosts")] + [Authorize(Policy = Policies.LiveTvManagement)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult DeleteTunerHost([FromQuery] string? id) + { + var config = _configurationManager.GetConfiguration("livetv"); + config.TunerHosts = config.TunerHosts.Where(i => !string.Equals(id, i.Id, StringComparison.OrdinalIgnoreCase)).ToArray(); + _configurationManager.SaveConfiguration("livetv", config); + return NoContent(); + } + + /// + /// Gets default listings provider info. + /// + /// Default listings provider info returned. + /// An containing the default listings provider info. + [HttpGet("ListingProviders/Default")] + [Authorize(Policy = Policies.LiveTvAccess)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult GetDefaultListingProvider() + { + return new ListingsProviderInfo(); + } + + /// + /// Adds a listings provider. + /// + /// Password. + /// New listings info. + /// Validate listings. + /// Validate login. + /// Created listings provider returned. + /// A containing the created listings provider. + [HttpPost("ListingProviders")] + [Authorize(Policy = Policies.LiveTvManagement)] + [ProducesResponseType(StatusCodes.Status200OK)] + [SuppressMessage("Microsoft.Performance", "CA5350:RemoveSha1", MessageId = "AddListingProvider", Justification = "Imported from ServiceStack")] + public async Task> 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(); } - /// - /// Delete listing provider. - /// - /// Listing provider id. - /// Listing provider deleted. - /// A . - [HttpDelete("ListingProviders")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult DeleteListingProvider([FromQuery] string? id) + return await _liveTvManager.SaveListingProvider(listingsProviderInfo, validateLogin, validateListings).ConfigureAwait(false); + } + + /// + /// Delete listing provider. + /// + /// Listing provider id. + /// Listing provider deleted. + /// A . + [HttpDelete("ListingProviders")] + [Authorize(Policy = Policies.LiveTvManagement)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult DeleteListingProvider([FromQuery] string? id) + { + _liveTvManager.DeleteListingsProvider(id); + return NoContent(); + } + + /// + /// Gets available lineups. + /// + /// Provider id. + /// Provider type. + /// Location. + /// Country. + /// Available lineups returned. + /// A containing the available lineups. + [HttpGet("ListingProviders/Lineups")] + [Authorize(Policy = Policies.LiveTvAccess)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task>> GetLineups( + [FromQuery] string? id, + [FromQuery] string? type, + [FromQuery] string? location, + [FromQuery] string? country) + { + return await _liveTvManager.GetLineups(type, id, country, location).ConfigureAwait(false); + } + + /// + /// Gets available countries. + /// + /// Available countries returned. + /// A containing the available countries. + [HttpGet("ListingProviders/SchedulesDirect/Countries")] + [Authorize(Policy = Policies.LiveTvAccess)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesFile(MediaTypeNames.Application.Json)] + public async Task 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 File(await response.Content.ReadAsStreamAsync().ConfigureAwait(false), MediaTypeNames.Application.Json); + } + + /// + /// Get channel mapping options. + /// + /// Provider id. + /// Channel mapping options returned. + /// An containing the channel mapping options. + [HttpGet("ChannelMappingOptions")] + [Authorize(Policy = Policies.LiveTvAccess)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetChannelMappingOptions([FromQuery] string? providerId) + { + var config = _configurationManager.GetConfiguration("livetv"); + + var listingsProviderInfo = config.ListingProviders.First(i => string.Equals(providerId, i.Id, StringComparison.OrdinalIgnoreCase)); + + var listingsProviderName = _liveTvManager.ListingProviders.First(i => string.Equals(i.Type, listingsProviderInfo.Type, StringComparison.OrdinalIgnoreCase)).Name; + + var tunerChannels = await _liveTvManager.GetChannelsForListingsProvider(providerId, CancellationToken.None) + .ConfigureAwait(false); + + var providerChannels = await _liveTvManager.GetChannelsFromListingsProviderData(providerId, CancellationToken.None) + .ConfigureAwait(false); + + var mappings = listingsProviderInfo.ChannelMappings; + + return new ChannelMappingOptionsDto { - _liveTvManager.DeleteListingsProvider(id); - return NoContent(); - } - - /// - /// Gets available lineups. - /// - /// Provider id. - /// Provider type. - /// Location. - /// Country. - /// Available lineups returned. - /// A containing the available lineups. - [HttpGet("ListingProviders/Lineups")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task>> GetLineups( - [FromQuery] string? id, - [FromQuery] string? type, - [FromQuery] string? location, - [FromQuery] string? country) - { - return await _liveTvManager.GetLineups(type, id, country, location).ConfigureAwait(false); - } - - /// - /// Gets available countries. - /// - /// Available countries returned. - /// A containing the available countries. - [HttpGet("ListingProviders/SchedulesDirect/Countries")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesFile(MediaTypeNames.Application.Json)] - public async Task 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 File(await response.Content.ReadAsStreamAsync().ConfigureAwait(false), MediaTypeNames.Application.Json); - } - - /// - /// Get channel mapping options. - /// - /// Provider id. - /// Channel mapping options returned. - /// An containing the channel mapping options. - [HttpGet("ChannelMappingOptions")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> GetChannelMappingOptions([FromQuery] string? providerId) - { - var config = _configurationManager.GetConfiguration("livetv"); - - var listingsProviderInfo = config.ListingProviders.First(i => string.Equals(providerId, i.Id, StringComparison.OrdinalIgnoreCase)); - - var listingsProviderName = _liveTvManager.ListingProviders.First(i => string.Equals(i.Type, listingsProviderInfo.Type, StringComparison.OrdinalIgnoreCase)).Name; - - var tunerChannels = await _liveTvManager.GetChannelsForListingsProvider(providerId, CancellationToken.None) - .ConfigureAwait(false); - - var providerChannels = await _liveTvManager.GetChannelsFromListingsProviderData(providerId, CancellationToken.None) - .ConfigureAwait(false); - - var mappings = listingsProviderInfo.ChannelMappings; - - return new ChannelMappingOptionsDto + TunerChannels = tunerChannels.Select(i => _liveTvManager.GetTunerChannelMapping(i, mappings, providerChannels)).ToList(), + ProviderChannels = providerChannels.Select(i => new NameIdPair { - 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 - }; - } + Name = i.Name, + Id = i.Id + }).ToList(), + Mappings = mappings, + ProviderName = listingsProviderName + }; + } - /// - /// Set channel mappings. - /// - /// The set channel mapping dto. - /// Created channel mapping returned. - /// An containing the created channel mapping. - [HttpPost("ChannelMappings")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> SetChannelMapping([FromBody, Required] SetChannelMappingDto setChannelMappingDto) + /// + /// Set channel mappings. + /// + /// The set channel mapping dto. + /// Created channel mapping returned. + /// An containing the created channel mapping. + [HttpPost("ChannelMappings")] + [Authorize(Policy = Policies.LiveTvManagement)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> SetChannelMapping([FromBody, Required] SetChannelMappingDto setChannelMappingDto) + { + return await _liveTvManager.SetChannelMapping(setChannelMappingDto.ProviderId, setChannelMappingDto.TunerChannelId, setChannelMappingDto.ProviderChannelId).ConfigureAwait(false); + } + + /// + /// Get tuner host types. + /// + /// Tuner host types returned. + /// An containing the tuner host types. + [HttpGet("TunerHosts/Types")] + [Authorize(Policy = Policies.LiveTvAccess)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> GetTunerHostTypes() + { + return _liveTvManager.GetTunerHostTypes(); + } + + /// + /// Discover tuners. + /// + /// Only discover new tuners. + /// Tuners returned. + /// An containing the tuners. + [HttpGet("Tuners/Discvover", Name = "DiscvoverTuners")] + [HttpGet("Tuners/Discover")] + [Authorize(Policy = Policies.LiveTvManagement)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task>> DiscoverTuners([FromQuery] bool newDevicesOnly = false) + { + return await _liveTvManager.DiscoverTuners(newDevicesOnly, CancellationToken.None).ConfigureAwait(false); + } + + /// + /// Gets a live tv recording stream. + /// + /// Recording id. + /// Recording stream returned. + /// Recording not found. + /// + /// An containing the recording stream on success, + /// or a if recording not found. + /// + [HttpGet("LiveRecordings/{recordingId}/stream")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesVideoFile] + public ActionResult GetLiveRecordingFile([FromRoute, Required] string recordingId) + { + var path = _liveTvManager.GetEmbyTvActiveRecordingPath(recordingId); + + if (string.IsNullOrWhiteSpace(path)) { - return await _liveTvManager.SetChannelMapping(setChannelMappingDto.ProviderId, setChannelMappingDto.TunerChannelId, setChannelMappingDto.ProviderChannelId).ConfigureAwait(false); + return NotFound(); } - /// - /// Get tuner host types. - /// - /// Tuner host types returned. - /// An containing the tuner host types. - [HttpGet("TunerHosts/Types")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> GetTunerHostTypes() + var stream = new ProgressiveFileStream(path, null, _transcodingJobHelper); + return new FileStreamResult(stream, MimeTypes.GetMimeType(path)); + } + + /// + /// Gets a live tv channel stream. + /// + /// Stream id. + /// Container type. + /// Stream returned. + /// Stream not found. + /// + /// An containing the channel stream on success, + /// or a if stream not found. + /// + [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 _liveTvManager.GetTunerHostTypes(); + return NotFound(); } - /// - /// Discover tuners. - /// - /// Only discover new tuners. - /// Tuners returned. - /// An containing the tuners. - [HttpGet("Tuners/Discvover", Name = "DiscvoverTuners")] - [HttpGet("Tuners/Discover")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task>> DiscoverTuners([FromQuery] bool newDevicesOnly = false) - { - return await _liveTvManager.DiscoverTuners(newDevicesOnly, CancellationToken.None).ConfigureAwait(false); - } - - /// - /// Gets a live tv recording stream. - /// - /// Recording id. - /// Recording stream returned. - /// Recording not found. - /// - /// An containing the recording stream on success, - /// or a if recording not found. - /// - [HttpGet("LiveRecordings/{recordingId}/stream")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesVideoFile] - public ActionResult GetLiveRecordingFile([FromRoute, Required] string recordingId) - { - var path = _liveTvManager.GetEmbyTvActiveRecordingPath(recordingId); - - if (string.IsNullOrWhiteSpace(path)) - { - return NotFound(); - } - - var stream = new ProgressiveFileStream(path, null, _transcodingJobHelper); - return new FileStreamResult(stream, MimeTypes.GetMimeType(path)); - } - - /// - /// Gets a live tv channel stream. - /// - /// Stream id. - /// Container type. - /// Stream returned. - /// Stream not found. - /// - /// An containing the channel stream on success, - /// or a if stream not found. - /// - [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)); - } - - 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."); - } - - if (!user.HasPermission(PermissionKind.EnableLiveTvManagement)) - { - throw new SecurityException("The current user does not have permission to manage live tv."); - } - } + var liveStream = new ProgressiveFileStream(liveStreamInfo.GetStream()); + return new FileStreamResult(liveStream, MimeTypes.GetMimeType("file." + container)); } } diff --git a/Jellyfin.Api/Controllers/LocalizationController.cs b/Jellyfin.Api/Controllers/LocalizationController.cs index 3d8b9e0cac..b9772a0693 100644 --- a/Jellyfin.Api/Controllers/LocalizationController.cs +++ b/Jellyfin.Api/Controllers/LocalizationController.cs @@ -6,71 +6,70 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// Localization controller. +/// +[Authorize(Policy = Policies.FirstTimeSetupOrDefault)] +public class LocalizationController : BaseJellyfinApiController { + private readonly ILocalizationManager _localization; + /// - /// Localization controller. + /// Initializes a new instance of the class. /// - [Authorize(Policy = Policies.FirstTimeSetupOrDefault)] - public class LocalizationController : BaseJellyfinApiController + /// Instance of the interface. + public LocalizationController(ILocalizationManager localization) { - private readonly ILocalizationManager _localization; + _localization = localization; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - public LocalizationController(ILocalizationManager localization) - { - _localization = localization; - } + /// + /// Gets known cultures. + /// + /// Known cultures returned. + /// An containing the list of cultures. + [HttpGet("Cultures")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> GetCultures() + { + return Ok(_localization.GetCultures()); + } - /// - /// Gets known cultures. - /// - /// Known cultures returned. - /// An containing the list of cultures. - [HttpGet("Cultures")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> GetCultures() - { - return Ok(_localization.GetCultures()); - } + /// + /// Gets known countries. + /// + /// Known countries returned. + /// An containing the list of countries. + [HttpGet("Countries")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> GetCountries() + { + return Ok(_localization.GetCountries()); + } - /// - /// Gets known countries. - /// - /// Known countries returned. - /// An containing the list of countries. - [HttpGet("Countries")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> GetCountries() - { - return Ok(_localization.GetCountries()); - } + /// + /// Gets known parental ratings. + /// + /// Known parental ratings returned. + /// An containing the list of parental ratings. + [HttpGet("ParentalRatings")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> GetParentalRatings() + { + return Ok(_localization.GetParentalRatings()); + } - /// - /// Gets known parental ratings. - /// - /// Known parental ratings returned. - /// An containing the list of parental ratings. - [HttpGet("ParentalRatings")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> GetParentalRatings() - { - return Ok(_localization.GetParentalRatings()); - } - - /// - /// Gets localization options. - /// - /// Localization options returned. - /// An containing the list of localization options. - [HttpGet("Options")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> GetLocalizationOptions() - { - return Ok(_localization.GetLocalizationOptions()); - } + /// + /// Gets localization options. + /// + /// Localization options returned. + /// An containing the list of localization options. + [HttpGet("Options")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> GetLocalizationOptions() + { + return Ok(_localization.GetLocalizationOptions()); } } diff --git a/Jellyfin.Api/Controllers/MediaInfoController.cs b/Jellyfin.Api/Controllers/MediaInfoController.cs index 8115c35852..da24616ff3 100644 --- a/Jellyfin.Api/Controllers/MediaInfoController.cs +++ b/Jellyfin.Api/Controllers/MediaInfoController.cs @@ -5,7 +5,6 @@ using System.Linq; using System.Net.Mime; using System.Threading.Tasks; using Jellyfin.Api.Attributes; -using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.Models.MediaInfoDtos; @@ -19,295 +18,297 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// The media info controller. +/// +[Route("")] +[Authorize] +public class MediaInfoController : BaseJellyfinApiController { + private readonly IMediaSourceManager _mediaSourceManager; + private readonly IDeviceManager _deviceManager; + private readonly ILibraryManager _libraryManager; + private readonly ILogger _logger; + private readonly MediaInfoHelper _mediaInfoHelper; + /// - /// The media info controller. + /// Initializes a new instance of the class. /// - [Route("")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class MediaInfoController : BaseJellyfinApiController + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the . + public MediaInfoController( + IMediaSourceManager mediaSourceManager, + IDeviceManager deviceManager, + ILibraryManager libraryManager, + ILogger logger, + MediaInfoHelper mediaInfoHelper) { - private readonly IMediaSourceManager _mediaSourceManager; - private readonly IDeviceManager _deviceManager; - private readonly ILibraryManager _libraryManager; - private readonly ILogger _logger; - private readonly MediaInfoHelper _mediaInfoHelper; + _mediaSourceManager = mediaSourceManager; + _deviceManager = deviceManager; + _libraryManager = libraryManager; + _logger = logger; + _mediaInfoHelper = mediaInfoHelper; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the . - public MediaInfoController( - IMediaSourceManager mediaSourceManager, - IDeviceManager deviceManager, - ILibraryManager libraryManager, - ILogger logger, - MediaInfoHelper mediaInfoHelper) + /// + /// Gets live playback media info for an item. + /// + /// The item id. + /// The user id. + /// Playback info returned. + /// A containing a with the playback information. + [HttpGet("Items/{itemId}/PlaybackInfo")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetPlaybackInfo([FromRoute, Required] Guid itemId, [FromQuery, Required] Guid userId) + { + return await _mediaInfoHelper.GetPlaybackInfo( + itemId, + userId) + .ConfigureAwait(false); + } + + /// + /// Gets live playback media info for an item. + /// + /// + /// For backwards compatibility parameters can be sent via Query or Body, with Query having higher precedence. + /// Query parameters are obsolete. + /// + /// The item id. + /// The user id. + /// The maximum streaming bitrate. + /// The start time in ticks. + /// The audio stream index. + /// The subtitle stream index. + /// The maximum number of audio channels. + /// The media source id. + /// The livestream id. + /// Whether to auto open the livestream. + /// Whether to enable direct play. Default: true. + /// Whether to enable direct stream. Default: true. + /// Whether to enable transcoding. Default: true. + /// Whether to allow to copy the video stream. Default: true. + /// Whether to allow to copy the audio stream. Default: true. + /// The playback info. + /// Playback info returned. + /// A containing a with the playback info. + [HttpPost("Items/{itemId}/PlaybackInfo")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> 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); + + if (profile is null) { - _mediaSourceManager = mediaSourceManager; - _deviceManager = deviceManager; - _libraryManager = libraryManager; - _logger = logger; - _mediaInfoHelper = mediaInfoHelper; + var caps = _deviceManager.GetCapabilities(User.GetDeviceId()); + if (caps is not null) + { + profile = caps.DeviceProfile; + } } - /// - /// Gets live playback media info for an item. - /// - /// The item id. - /// The user id. - /// Playback info returned. - /// A containing a with the playback information. - [HttpGet("Items/{itemId}/PlaybackInfo")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> GetPlaybackInfo([FromRoute, Required] Guid itemId, [FromQuery, Required] Guid userId) + // 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); + + if (info.ErrorCode is not null) { - return await _mediaInfoHelper.GetPlaybackInfo( - itemId, - userId) - .ConfigureAwait(false); - } - - /// - /// Gets live playback media info for an item. - /// - /// - /// For backwards compatibility parameters can be sent via Query or Body, with Query having higher precedence. - /// Query parameters are obsolete. - /// - /// The item id. - /// The user id. - /// The maximum streaming bitrate. - /// The start time in ticks. - /// The audio stream index. - /// The subtitle stream index. - /// The maximum number of audio channels. - /// The media source id. - /// The livestream id. - /// Whether to auto open the livestream. - /// Whether to enable direct play. Default: true. - /// Whether to enable direct stream. Default: true. - /// Whether to enable transcoding. Default: true. - /// Whether to allow to copy the video stream. Default: true. - /// Whether to allow to copy the audio stream. Default: true. - /// The playback info. - /// Playback info returned. - /// A containing a with the playback info. - [HttpPost("Items/{itemId}/PlaybackInfo")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> 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); - - if (profile is null) - { - var caps = _deviceManager.GetCapabilities(User.GetDeviceId()); - if (caps is not null) - { - 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; - - var info = await _mediaInfoHelper.GetPlaybackInfo( - itemId, - userId, - mediaSourceId, - liveStreamId) - .ConfigureAwait(false); - - if (info.ErrorCode is not null) - { - return info; - } - - if (profile is not null) - { - // set device specific data - var item = _libraryManager.GetItemById(itemId); - - 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); - } - - if (autoOpenLiveStream.Value) - { - 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); - - info.MediaSources = new[] { openStreamResult.MediaSource }; - } - } - return info; } - /// - /// Opens a media source. - /// - /// The open token. - /// The user id. - /// The play session id. - /// The maximum streaming bitrate. - /// The start time in ticks. - /// The audio stream index. - /// The subtitle stream index. - /// The maximum number of audio channels. - /// The item id. - /// The open live stream dto. - /// Whether to enable direct play. Default: true. - /// Whether to enable direct stream. Default: true. - /// Media source opened. - /// A containing a . - [HttpPost("LiveStreams/Open")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> 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) + if (profile is not null) { - var request = new LiveStreamRequest + // set device specific data + var item = _libraryManager.GetItemById(itemId); + + foreach (var mediaSource in info.MediaSources) { - 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); + _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); } - /// - /// Closes a media source. - /// - /// The livestream id. - /// Livestream closed. - /// A indicating success. - [HttpPost("LiveStreams/Close")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task CloseLiveStream([FromQuery, Required] string liveStreamId) + if (autoOpenLiveStream.Value) { - await _mediaSourceManager.CloseLiveStream(liveStreamId).ConfigureAwait(false); - return NoContent(); + 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); + + info.MediaSources = new[] { openStreamResult.MediaSource }; + } } - /// - /// Tests the network with a request with the size of the bitrate. - /// - /// The bitrate. Defaults to 102400. - /// Test buffer returned. - /// A with specified bitrate. - [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) + return info; + } + + /// + /// Opens a media source. + /// + /// The open token. + /// The user id. + /// The play session id. + /// The maximum streaming bitrate. + /// The start time in ticks. + /// The audio stream index. + /// The subtitle stream index. + /// The maximum number of audio channels. + /// The item id. + /// The open live stream dto. + /// Whether to enable direct play. Default: true. + /// Whether to enable direct stream. Default: true. + /// Media source opened. + /// A containing a . + [HttpPost("LiveStreams/Open")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> 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 { - byte[] buffer = ArrayPool.Shared.Rent(size); - try - { - Random.Shared.NextBytes(buffer); - return File(buffer, MediaTypeNames.Application.Octet); - } - finally - { - ArrayPool.Shared.Return(buffer); - } + 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); + } + + /// + /// Closes a media source. + /// + /// The livestream id. + /// Livestream closed. + /// A indicating success. + [HttpPost("LiveStreams/Close")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task CloseLiveStream([FromQuery, Required] string liveStreamId) + { + await _mediaSourceManager.CloseLiveStream(liveStreamId).ConfigureAwait(false); + return NoContent(); + } + + /// + /// Tests the network with a request with the size of the bitrate. + /// + /// The bitrate. Defaults to 102400. + /// Test buffer returned. + /// A with specified bitrate. + [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.Shared.Rent(size); + try + { + Random.Shared.NextBytes(buffer); + return File(buffer, MediaTypeNames.Application.Octet); + } + finally + { + ArrayPool.Shared.Return(buffer); } } } diff --git a/Jellyfin.Api/Controllers/MoviesController.cs b/Jellyfin.Api/Controllers/MoviesController.cs index 3cf079362b..e1145481fa 100644 --- a/Jellyfin.Api/Controllers/MoviesController.cs +++ b/Jellyfin.Api/Controllers/MoviesController.cs @@ -2,8 +2,8 @@ using System; using System.Collections.Generic; using System.Globalization; using System.Linq; -using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; @@ -18,122 +18,123 @@ using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers -{ - /// - /// Movies controller. - /// - [Authorize(Policy = Policies.DefaultAuthorization)] - public class MoviesController : BaseJellyfinApiController - { - private readonly IUserManager _userManager; - private readonly ILibraryManager _libraryManager; - private readonly IDtoService _dtoService; - private readonly IServerConfigurationManager _serverConfigurationManager; +namespace Jellyfin.Api.Controllers; - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - public MoviesController( - IUserManager userManager, - ILibraryManager libraryManager, - IDtoService dtoService, - IServerConfigurationManager serverConfigurationManager) +/// +/// Movies controller. +/// +[Authorize] +public class MoviesController : BaseJellyfinApiController +{ + private readonly IUserManager _userManager; + private readonly ILibraryManager _libraryManager; + private readonly IDtoService _dtoService; + private readonly IServerConfigurationManager _serverConfigurationManager; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + public MoviesController( + IUserManager userManager, + ILibraryManager libraryManager, + IDtoService dtoService, + IServerConfigurationManager serverConfigurationManager) + { + _userManager = userManager; + _libraryManager = libraryManager; + _dtoService = dtoService; + _serverConfigurationManager = serverConfigurationManager; + } + + /// + /// Gets movie recommendations. + /// + /// Optional. Filter by user id, and attach user data. + /// Specify this to localize the search to a specific item or folder. Omit to use the root. + /// Optional. The fields to return. + /// The max number of categories to return. + /// The max number of items to return per category. + /// Movie recommendations returned. + /// The list of movie recommendations. + [HttpGet("Recommendations")] + public ActionResult> 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 categories = new List(); + + var parentIdGuid = parentId ?? Guid.Empty; + + var query = new InternalItemsQuery(user) { - _userManager = userManager; - _libraryManager = libraryManager; - _dtoService = dtoService; - _serverConfigurationManager = serverConfigurationManager; + IncludeItemTypes = new[] + { + BaseItemKind.Movie, + // nameof(Trailer), + // nameof(LiveTvProgram) + }, + // IsMovie = true + OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.Random, SortOrder.Descending) }, + Limit = 7, + ParentId = parentIdGuid, + Recursive = true, + IsPlayed = true, + DtoOptions = dtoOptions + }; + + var recentlyPlayedMovies = _libraryManager.GetItemList(query); + + var itemTypes = new List { BaseItemKind.Movie }; + if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) + { + itemTypes.Add(BaseItemKind.Trailer); + itemTypes.Add(BaseItemKind.LiveTvProgram); } - /// - /// Gets movie recommendations. - /// - /// Optional. Filter by user id, and attach user data. - /// Specify this to localize the search to a specific item or folder. Omit to use the root. - /// Optional. The fields to return. - /// The max number of categories to return. - /// The max number of items to return per category. - /// Movie recommendations returned. - /// The list of movie recommendations. - [HttpGet("Recommendations")] - public ActionResult> GetMovieRecommendations( - [FromQuery] Guid? userId, - [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] int categoryLimit = 5, - [FromQuery] int itemLimit = 8) + var likedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user) { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(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 categories = new List(); + var mostRecentMovies = recentlyPlayedMovies.GetRange(0, Math.Min(recentlyPlayedMovies.Count, 6)); + // Get recently played directors + var recentDirectors = GetDirectors(mostRecentMovies) + .ToList(); - var parentIdGuid = parentId ?? Guid.Empty; + // Get recently played actors + var recentActors = GetActors(mostRecentMovies) + .ToList(); - var query = new InternalItemsQuery(user) - { - IncludeItemTypes = new[] - { - BaseItemKind.Movie, - // nameof(Trailer), - // nameof(LiveTvProgram) - }, - // IsMovie = true - OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.Random, SortOrder.Descending) }, - Limit = 7, - ParentId = parentIdGuid, - Recursive = true, - IsPlayed = true, - DtoOptions = dtoOptions - }; + var similarToRecentlyPlayed = GetSimilarTo(user, recentlyPlayedMovies, itemLimit, dtoOptions, RecommendationType.SimilarToRecentlyPlayed).GetEnumerator(); + var similarToLiked = GetSimilarTo(user, likedMovies, itemLimit, dtoOptions, RecommendationType.SimilarToLikedItem).GetEnumerator(); - var recentlyPlayedMovies = _libraryManager.GetItemList(query); + var hasDirectorFromRecentlyPlayed = GetWithDirector(user, recentDirectors, itemLimit, dtoOptions, RecommendationType.HasDirectorFromRecentlyPlayed).GetEnumerator(); + var hasActorFromRecentlyPlayed = GetWithActor(user, recentActors, itemLimit, dtoOptions, RecommendationType.HasActorFromRecentlyPlayed).GetEnumerator(); - var itemTypes = new List { BaseItemKind.Movie }; - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) - { - itemTypes.Add(BaseItemKind.Trailer); - itemTypes.Add(BaseItemKind.LiveTvProgram); - } - - var likedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user) - { - IncludeItemTypes = itemTypes.ToArray(), - IsMovie = true, - OrderBy = new[] { (ItemSortBy.Random, SortOrder.Descending) }, - Limit = 10, - IsFavoriteOrLiked = true, - ExcludeItemIds = recentlyPlayedMovies.Select(i => i.Id).ToArray(), - EnableGroupByMetadataKey = true, - ParentId = parentIdGuid, - Recursive = true, - DtoOptions = dtoOptions - }); - - var mostRecentMovies = recentlyPlayedMovies.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> + var categoryTypes = new List> { // Give this extra weight similarToRecentlyPlayed, @@ -146,181 +147,180 @@ namespace Jellyfin.Api.Controllers hasActorFromRecentlyPlayed }; - while (categories.Count < categoryLimit) + while (categories.Count < categoryLimit) + { + var allEmpty = true; + + foreach (var category in categoryTypes) { - var allEmpty = true; - - 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 GetWithDirector( - User? user, - IEnumerable names, - int itemLimit, - DtoOptions dtoOptions, - RecommendationType type) - { - var itemTypes = new List { BaseItemKind.Movie }; - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) + if (allEmpty) { - itemTypes.Add(BaseItemKind.Trailer); - itemTypes.Add(BaseItemKind.LiveTvProgram); - } - - foreach (var name in names) - { - var items = _libraryManager.GetItemList( - new InternalItemsQuery(user) - { - Person = name, - // Account for duplicates by IMDb id, since the database doesn't support this yet - Limit = itemLimit + 2, - PersonTypes = new[] { PersonType.Director }, - IncludeItemTypes = itemTypes.ToArray(), - IsMovie = true, - EnableGroupByMetadataKey = true, - DtoOptions = dtoOptions - }).DistinctBy(i => i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)) - .Take(itemLimit) - .ToList(); - - if (items.Count > 0) - { - var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user); - - yield return new RecommendationDto - { - BaselineItemName = name, - CategoryId = name.GetMD5(), - RecommendationType = type, - Items = returnItems - }; - } + break; } } - private IEnumerable GetWithActor(User? user, IEnumerable names, int itemLimit, DtoOptions dtoOptions, RecommendationType type) - { - var itemTypes = new List { BaseItemKind.Movie }; - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) - { - itemTypes.Add(BaseItemKind.Trailer); - itemTypes.Add(BaseItemKind.LiveTvProgram); - } + return Ok(categories.OrderBy(i => i.RecommendationType).AsEnumerable()); + } - foreach (var name in names) - { - var items = _libraryManager.GetItemList(new InternalItemsQuery(user) + private IEnumerable GetWithDirector( + User? user, + IEnumerable names, + int itemLimit, + DtoOptions dtoOptions, + RecommendationType type) + { + var itemTypes = new List { BaseItemKind.Movie }; + if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) + { + itemTypes.Add(BaseItemKind.Trailer); + itemTypes.Add(BaseItemKind.LiveTvProgram); + } + + foreach (var name in names) + { + var items = _libraryManager.GetItemList( + new InternalItemsQuery(user) { Person = name, // Account for duplicates by IMDb id, since the database doesn't support this yet Limit = itemLimit + 2, + PersonTypes = new[] { PersonType.Director }, IncludeItemTypes = itemTypes.ToArray(), IsMovie = true, EnableGroupByMetadataKey = true, DtoOptions = dtoOptions }).DistinctBy(i => i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)) - .Take(itemLimit) - .ToList(); + .Take(itemLimit) + .ToList(); - if (items.Count > 0) - { - var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user); - - yield return new RecommendationDto - { - BaselineItemName = name, - CategoryId = name.GetMD5(), - RecommendationType = type, - Items = returnItems - }; - } - } - } - - private IEnumerable GetSimilarTo(User? user, IEnumerable baselineItems, int itemLimit, DtoOptions dtoOptions, RecommendationType type) - { - var itemTypes = new List { BaseItemKind.Movie }; - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) + if (items.Count > 0) { - itemTypes.Add(BaseItemKind.Trailer); - itemTypes.Add(BaseItemKind.LiveTvProgram); - } + var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user); - foreach (var item in baselineItems) - { - var similar = _libraryManager.GetItemList(new InternalItemsQuery(user) + yield return new RecommendationDto { - Limit = itemLimit, - IncludeItemTypes = itemTypes.ToArray(), - IsMovie = true, - SimilarTo = item, - EnableGroupByMetadataKey = true, - DtoOptions = dtoOptions - }); - - if (similar.Count > 0) - { - var returnItems = _dtoService.GetBaseItemDtos(similar, dtoOptions, user); - - yield return new RecommendationDto - { - BaselineItemName = item.Name, - CategoryId = item.Id, - RecommendationType = type, - Items = returnItems - }; - } + BaselineItemName = name, + CategoryId = name.GetMD5(), + RecommendationType = type, + Items = returnItems + }; } } - - private IEnumerable GetActors(IEnumerable items) - { - var people = _libraryManager.GetPeople(new InternalPeopleQuery(Array.Empty(), new[] { PersonType.Director }) - { - MaxListOrder = 3 - }); - - var itemIds = items.Select(i => i.Id).ToList(); - - return people - .Where(i => itemIds.Contains(i.ItemId)) - .Select(i => i.Name) - .DistinctNames(); - } - - private IEnumerable GetDirectors(IEnumerable items) - { - var people = _libraryManager.GetPeople(new InternalPeopleQuery( - new[] { PersonType.Director }, - Array.Empty())); - - var itemIds = items.Select(i => i.Id).ToList(); - - return people - .Where(i => itemIds.Contains(i.ItemId)) - .Select(i => i.Name) - .DistinctNames(); - } + } + + private IEnumerable GetWithActor(User? user, IEnumerable names, int itemLimit, DtoOptions dtoOptions, RecommendationType type) + { + var itemTypes = new List { BaseItemKind.Movie }; + if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) + { + itemTypes.Add(BaseItemKind.Trailer); + itemTypes.Add(BaseItemKind.LiveTvProgram); + } + + foreach (var name in names) + { + var items = _libraryManager.GetItemList(new InternalItemsQuery(user) + { + Person = name, + // Account for duplicates by IMDb id, since the database doesn't support this yet + Limit = itemLimit + 2, + IncludeItemTypes = itemTypes.ToArray(), + IsMovie = true, + EnableGroupByMetadataKey = true, + DtoOptions = dtoOptions + }).DistinctBy(i => i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)) + .Take(itemLimit) + .ToList(); + + if (items.Count > 0) + { + var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user); + + yield return new RecommendationDto + { + BaselineItemName = name, + CategoryId = name.GetMD5(), + RecommendationType = type, + Items = returnItems + }; + } + } + } + + private IEnumerable GetSimilarTo(User? user, IEnumerable baselineItems, int itemLimit, DtoOptions dtoOptions, RecommendationType type) + { + var itemTypes = new List { BaseItemKind.Movie }; + if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) + { + itemTypes.Add(BaseItemKind.Trailer); + itemTypes.Add(BaseItemKind.LiveTvProgram); + } + + foreach (var item in baselineItems) + { + var similar = _libraryManager.GetItemList(new InternalItemsQuery(user) + { + Limit = itemLimit, + IncludeItemTypes = itemTypes.ToArray(), + IsMovie = true, + SimilarTo = item, + EnableGroupByMetadataKey = true, + DtoOptions = dtoOptions + }); + + if (similar.Count > 0) + { + var returnItems = _dtoService.GetBaseItemDtos(similar, dtoOptions, user); + + yield return new RecommendationDto + { + BaselineItemName = item.Name, + CategoryId = item.Id, + RecommendationType = type, + Items = returnItems + }; + } + } + } + + private IEnumerable GetActors(IEnumerable items) + { + var people = _libraryManager.GetPeople(new InternalPeopleQuery(Array.Empty(), new[] { PersonType.Director }) + { + MaxListOrder = 3 + }); + + var itemIds = items.Select(i => i.Id).ToList(); + + return people + .Where(i => itemIds.Contains(i.ItemId)) + .Select(i => i.Name) + .DistinctNames(); + } + + private IEnumerable GetDirectors(IEnumerable items) + { + var people = _libraryManager.GetPeople(new InternalPeopleQuery( + new[] { PersonType.Director }, + Array.Empty())); + + var itemIds = items.Select(i => i.Id).ToList(); + + return people + .Where(i => itemIds.Contains(i.ItemId)) + .Select(i => i.Name) + .DistinctNames(); } } diff --git a/Jellyfin.Api/Controllers/MusicGenresController.cs b/Jellyfin.Api/Controllers/MusicGenresController.cs index f4fb5f44ab..435457af67 100644 --- a/Jellyfin.Api/Controllers/MusicGenresController.cs +++ b/Jellyfin.Api/Controllers/MusicGenresController.cs @@ -1,7 +1,6 @@ using System; using System.ComponentModel.DataAnnotations; using System.Linq; -using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; @@ -18,181 +17,187 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// The music genres controller. +/// +[Authorize] +public class MusicGenresController : BaseJellyfinApiController { + private readonly ILibraryManager _libraryManager; + private readonly IDtoService _dtoService; + private readonly IUserManager _userManager; + /// - /// The music genres controller. + /// Initializes a new instance of the class. /// - [Authorize(Policy = Policies.DefaultAuthorization)] - public class MusicGenresController : BaseJellyfinApiController + /// Instance of interface. + /// Instance of interface. + /// Instance of interface. + public MusicGenresController( + ILibraryManager libraryManager, + IUserManager userManager, + IDtoService dtoService) { - private readonly ILibraryManager _libraryManager; - private readonly IDtoService _dtoService; - private readonly IUserManager _userManager; + _libraryManager = libraryManager; + _userManager = userManager; + _dtoService = dtoService; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of interface. - /// Instance of interface. - /// Instance of interface. - public MusicGenresController( - ILibraryManager libraryManager, - IUserManager userManager, - IDtoService dtoService) + /// + /// Gets all music genres from a given item, folder, or the entire library. + /// + /// Optional. The record index to start at. All items with a lower index will be dropped from the results. + /// Optional. The maximum number of records to return. + /// The search term. + /// Specify this to localize the search to a specific item or folder. Omit to use the root. + /// Optional. Specify additional fields of information to return in the output. + /// Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited. + /// Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited. + /// Optional filter by items that are marked as favorite, or not. + /// Optional, the max number of images to return, per image type. + /// Optional. The image types to include in the output. + /// User id. + /// Optional filter by items whose name is sorted equally or greater than a given input string. + /// Optional filter by items whose name is sorted equally than a given input string. + /// Optional filter by items whose name is equally or lesser than a given input string. + /// Optional. Specify one or more sort orders, comma delimited. + /// Sort Order - Ascending,Descending. + /// Optional, include image information in output. + /// Optional. Include total record count. + /// Music genres returned. + /// An containing the queryresult of music genres. + [HttpGet] + [Obsolete("Use GetGenres instead")] + public ActionResult> 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.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + + var parentItem = _libraryManager.GetParentItem(parentId, userId); + + var query = new InternalItemsQuery(user) { - _libraryManager = libraryManager; - _userManager = userManager; - _dtoService = dtoService; - } + 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) + }; - /// - /// Gets all music genres from a given item, folder, or the entire library. - /// - /// Optional. The record index to start at. All items with a lower index will be dropped from the results. - /// Optional. The maximum number of records to return. - /// The search term. - /// Specify this to localize the search to a specific item or folder. Omit to use the root. - /// Optional. Specify additional fields of information to return in the output. - /// Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited. - /// Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited. - /// Optional filter by items that are marked as favorite, or not. - /// Optional, the max number of images to return, per image type. - /// Optional. The image types to include in the output. - /// User id. - /// Optional filter by items whose name is sorted equally or greater than a given input string. - /// Optional filter by items whose name is sorted equally than a given input string. - /// Optional filter by items whose name is equally or lesser than a given input string. - /// Optional. Specify one or more sort orders, comma delimited. - /// Sort Order - Ascending,Descending. - /// Optional, include image information in output. - /// Optional. Include total record count. - /// Music genres returned. - /// An containing the queryresult of music genres. - [HttpGet] - [Obsolete("Use GetGenres instead")] - public ActionResult> 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) + if (parentId.HasValue) { - 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); - - var parentItem = _libraryManager.GetParentItem(parentId, userId); - - var query = new InternalItemsQuery(user) + 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) - { - if (parentItem is Folder) - { - query.AncestorIds = new[] { parentId.Value }; - } - else - { - query.ItemIds = new[] { parentId.Value }; - } - } - - var result = _libraryManager.GetMusicGenres(query); - - var shouldIncludeItemTypes = includeItemTypes.Length != 0; - return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user); - } - - /// - /// Gets a music genre, by name. - /// - /// The genre name. - /// Optional. Filter by user id, and attach user data. - /// An containing a with the music genre. - [HttpGet("{genreName}")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult GetMusicGenre([FromRoute, Required] string genreName, [FromQuery] Guid? userId) - { - var dtoOptions = new DtoOptions().AddClientFields(User); - - MusicGenre? item; - - if (genreName.IndexOf(BaseItem.SlugChar, StringComparison.OrdinalIgnoreCase) != -1) - { - item = GetItemFromSlugName(_libraryManager, genreName, dtoOptions, BaseItemKind.MusicGenre); + query.AncestorIds = new[] { parentId.Value }; } else { - item = _libraryManager.GetMusicGenre(genreName); + query.ItemIds = new[] { parentId.Value }; } - - if (userId.HasValue && !userId.Value.Equals(default)) - { - var user = _userManager.GetUserById(userId.Value); - - return _dtoService.GetBaseItemDto(item, dtoOptions, user); - } - - return _dtoService.GetBaseItemDto(item, dtoOptions); } - private T? GetItemFromSlugName(ILibraryManager libraryManager, string name, DtoOptions dtoOptions, BaseItemKind baseItemKind) - where T : BaseItem, new() + var result = _libraryManager.GetMusicGenres(query); + + var shouldIncludeItemTypes = includeItemTypes.Length != 0; + return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user); + } + + /// + /// Gets a music genre, by name. + /// + /// The genre name. + /// Optional. Filter by user id, and attach user data. + /// An containing a with the music genre. + [HttpGet("{genreName}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult GetMusicGenre([FromRoute, Required] string genreName, [FromQuery] Guid? userId) + { + userId = RequestHelpers.GetUserId(User, userId); + var dtoOptions = new DtoOptions().AddClientFields(User); + + MusicGenre? item; + + if (genreName.IndexOf(BaseItem.SlugChar, StringComparison.OrdinalIgnoreCase) != -1) { - var result = libraryManager.GetItemList(new InternalItemsQuery - { - Name = name.Replace(BaseItem.SlugChar, '&'), - IncludeItemTypes = new[] { baseItemKind }, - DtoOptions = dtoOptions - }).OfType().FirstOrDefault(); - - result ??= libraryManager.GetItemList(new InternalItemsQuery - { - Name = name.Replace(BaseItem.SlugChar, '/'), - IncludeItemTypes = new[] { baseItemKind }, - DtoOptions = dtoOptions - }).OfType().FirstOrDefault(); - - result ??= libraryManager.GetItemList(new InternalItemsQuery - { - Name = name.Replace(BaseItem.SlugChar, '?'), - IncludeItemTypes = new[] { baseItemKind }, - DtoOptions = dtoOptions - }).OfType().FirstOrDefault(); - - return result; + item = GetItemFromSlugName(_libraryManager, genreName, dtoOptions, BaseItemKind.MusicGenre); } + else + { + item = _libraryManager.GetMusicGenre(genreName); + } + + if (item is null) + { + return NotFound(); + } + + if (!userId.Value.Equals(default)) + { + var user = _userManager.GetUserById(userId.Value); + + return _dtoService.GetBaseItemDto(item, dtoOptions, user); + } + + return _dtoService.GetBaseItemDto(item, dtoOptions); + } + + private T? GetItemFromSlugName(ILibraryManager libraryManager, string name, DtoOptions dtoOptions, BaseItemKind baseItemKind) + where T : BaseItem, new() + { + var result = libraryManager.GetItemList(new InternalItemsQuery + { + Name = name.Replace(BaseItem.SlugChar, '&'), + IncludeItemTypes = new[] { baseItemKind }, + DtoOptions = dtoOptions + }).OfType().FirstOrDefault(); + + result ??= libraryManager.GetItemList(new InternalItemsQuery + { + Name = name.Replace(BaseItem.SlugChar, '/'), + IncludeItemTypes = new[] { baseItemKind }, + DtoOptions = dtoOptions + }).OfType().FirstOrDefault(); + + result ??= libraryManager.GetItemList(new InternalItemsQuery + { + Name = name.Replace(BaseItem.SlugChar, '?'), + IncludeItemTypes = new[] { baseItemKind }, + DtoOptions = dtoOptions + }).OfType().FirstOrDefault(); + + return result; } } diff --git a/Jellyfin.Api/Controllers/PackageController.cs b/Jellyfin.Api/Controllers/PackageController.cs index 10f967dcde..0ba5e995fb 100644 --- a/Jellyfin.Api/Controllers/PackageController.cs +++ b/Jellyfin.Api/Controllers/PackageController.cs @@ -11,157 +11,156 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// Package Controller. +/// +[Route("")] +[Authorize] +public class PackageController : BaseJellyfinApiController { + private readonly IInstallationManager _installationManager; + private readonly IServerConfigurationManager _serverConfigurationManager; + /// - /// Package Controller. + /// Initializes a new instance of the class. /// - [Route("")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class PackageController : BaseJellyfinApiController + /// Instance of the interface. + /// Instance of the interface. + public PackageController(IInstallationManager installationManager, IServerConfigurationManager serverConfigurationManager) { - private readonly IInstallationManager _installationManager; - private readonly IServerConfigurationManager _serverConfigurationManager; + _installationManager = installationManager; + _serverConfigurationManager = serverConfigurationManager; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - public PackageController(IInstallationManager installationManager, IServerConfigurationManager serverConfigurationManager) + /// + /// Gets a package by name or assembly GUID. + /// + /// The name of the package. + /// The GUID of the associated assembly. + /// Package retrieved. + /// A containing package information. + [HttpGet("Packages/{name}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> 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) { - _installationManager = installationManager; - _serverConfigurationManager = serverConfigurationManager; + return NotFound(); } - /// - /// Gets a package by name or assembly GUID. - /// - /// The name of the package. - /// The GUID of the associated assembly. - /// Package retrieved. - /// A containing package information. - [HttpGet("Packages/{name}")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> GetPackageInfo( - [FromRoute, Required] string name, - [FromQuery] Guid? assemblyGuid) + return result; + } + + /// + /// Gets available packages. + /// + /// Available packages returned. + /// An containing available packages information. + [HttpGet("Packages")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetPackages() + { + IEnumerable packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false); + + return packages; + } + + /// + /// Installs a package. + /// + /// Package name. + /// GUID of the associated assembly. + /// Optional version. Defaults to latest version. + /// Optional. Specify the repository to install from. + /// Package found. + /// Package not found. + /// A on success, or a if the package could not be found. + [HttpPost("Packages/Installed/{name}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = Policies.RequiresElevation)] + public async Task 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)) { - var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false); - var result = _installationManager.FilterPackages( - packages, - name, - assemblyGuid ?? default) - .FirstOrDefault(); - - if (result is null) - { - return NotFound(); - } - - return result; + packages = packages.Where(p => p.Versions.Any(q => q.RepositoryUrl.Equals(repositoryUrl, StringComparison.OrdinalIgnoreCase))) + .ToList(); } - /// - /// Gets available packages. - /// - /// Available packages returned. - /// An containing available packages information. - [HttpGet("Packages")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> GetPackages() - { - IEnumerable packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false); + var package = _installationManager.GetCompatibleVersions( + packages, + name, + assemblyGuid ?? Guid.Empty, + specificVersion: string.IsNullOrEmpty(version) ? null : Version.Parse(version)) + .FirstOrDefault(); - return packages; + if (package is null) + { + return NotFound(); } - /// - /// Installs a package. - /// - /// Package name. - /// GUID of the associated assembly. - /// Optional version. Defaults to latest version. - /// Optional. Specify the repository to install from. - /// Package found. - /// Package not found. - /// A on success, or a if the package could not be found. - [HttpPost("Packages/Installed/{name}")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [Authorize(Policy = Policies.RequiresElevation)] - public async Task 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(); - } + await _installationManager.InstallPackage(package).ConfigureAwait(false); - var package = _installationManager.GetCompatibleVersions( - packages, - name, - assemblyGuid ?? Guid.Empty, - specificVersion: string.IsNullOrEmpty(version) ? null : Version.Parse(version)) - .FirstOrDefault(); + return NoContent(); + } - if (package is null) - { - return NotFound(); - } + /// + /// Cancels a package installation. + /// + /// Installation Id. + /// Installation cancelled. + /// A on successfully cancelling a package installation. + [HttpDelete("Packages/Installing/{packageId}")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult CancelPackageInstallation( + [FromRoute, Required] Guid packageId) + { + _installationManager.CancelInstallation(packageId); + return NoContent(); + } - await _installationManager.InstallPackage(package).ConfigureAwait(false); + /// + /// Gets all package repositories. + /// + /// Package repositories returned. + /// An containing the list of package repositories. + [HttpGet("Repositories")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> GetRepositories() + { + return Ok(_serverConfigurationManager.Configuration.PluginRepositories.AsEnumerable()); + } - return NoContent(); - } - - /// - /// Cancels a package installation. - /// - /// Installation Id. - /// Installation cancelled. - /// A on successfully cancelling a package installation. - [HttpDelete("Packages/Installing/{packageId}")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult CancelPackageInstallation( - [FromRoute, Required] Guid packageId) - { - _installationManager.CancelInstallation(packageId); - return NoContent(); - } - - /// - /// Gets all package repositories. - /// - /// Package repositories returned. - /// An containing the list of package repositories. - [HttpGet("Repositories")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> GetRepositories() - { - return Ok(_serverConfigurationManager.Configuration.PluginRepositories.AsEnumerable()); - } - - /// - /// Sets the enabled and existing package repositories. - /// - /// The list of package repositories. - /// Package repositories saved. - /// A . - [HttpPost("Repositories")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult SetRepositories([FromBody, Required] RepositoryInfo[] repositoryInfos) - { - _serverConfigurationManager.Configuration.PluginRepositories = repositoryInfos; - _serverConfigurationManager.SaveConfiguration(); - return NoContent(); - } + /// + /// Sets the enabled and existing package repositories. + /// + /// The list of package repositories. + /// Package repositories saved. + /// A . + [HttpPost("Repositories")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SetRepositories([FromBody, Required] RepositoryInfo[] repositoryInfos) + { + _serverConfigurationManager.Configuration.PluginRepositories = repositoryInfos; + _serverConfigurationManager.SaveConfiguration(); + return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/PersonsController.cs b/Jellyfin.Api/Controllers/PersonsController.cs index 09f7281ecb..b4c6f490a0 100644 --- a/Jellyfin.Api/Controllers/PersonsController.cs +++ b/Jellyfin.Api/Controllers/PersonsController.cs @@ -1,8 +1,8 @@ using System; using System.ComponentModel.DataAnnotations; using System.Linq; -using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Entities; using MediaBrowser.Controller.Dto; @@ -15,125 +15,126 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// Persons controller. +/// +[Authorize] +public class PersonsController : BaseJellyfinApiController { + private readonly ILibraryManager _libraryManager; + private readonly IDtoService _dtoService; + private readonly IUserManager _userManager; + /// - /// Persons controller. + /// Initializes a new instance of the class. /// - [Authorize(Policy = Policies.DefaultAuthorization)] - public class PersonsController : BaseJellyfinApiController + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + 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; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - public PersonsController( - ILibraryManager libraryManager, - IDtoService dtoService, - IUserManager userManager) + /// + /// Gets all persons. + /// + /// Optional. The maximum number of records to return. + /// The search term. + /// Optional. Specify additional fields of information to return in the output. + /// Optional. Specify additional filters to apply. + /// Optional filter by items that are marked as favorite, or not. userId is required. + /// Optional, include user data. + /// Optional, the max number of images to return, per image type. + /// Optional. The image types to include in the output. + /// Optional. If specified results will be filtered to exclude those containing the specified PersonType. Allows multiple, comma-delimited. + /// Optional. If specified results will be filtered to include only those containing the specified PersonType. Allows multiple, comma-delimited. + /// Optional. If specified, person results will be filtered on items related to said persons. + /// User id. + /// Optional, include image information in output. + /// Persons returned. + /// An containing the queryresult of persons. + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> 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); + + var isFavoriteInFilters = filters.Any(f => f == ItemFilter.IsFavorite); + var peopleItems = _libraryManager.GetPeopleItems(new InternalPeopleQuery( + personTypes, + excludePersonTypes) { - _libraryManager = libraryManager; - _dtoService = dtoService; - _userManager = userManager; + NameContains = searchTerm, + User = user, + IsFavorite = !isFavorite.HasValue && isFavoriteInFilters ? true : isFavorite, + AppearsInItemId = appearsInItemId ?? Guid.Empty, + Limit = limit ?? 0 + }); + + return new QueryResult( + peopleItems + .Select(person => _dtoService.GetItemByNameDto(person, dtoOptions, null, user)) + .ToArray()); + } + + /// + /// Get person by name. + /// + /// Person name. + /// Optional. Filter by user id, and attach user data. + /// Person returned. + /// Person not found. + /// An containing the person on success, + /// or a if person not found. + [HttpGet("{name}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult GetPerson([FromRoute, Required] string name, [FromQuery] Guid? userId) + { + userId = RequestHelpers.GetUserId(User, userId); + var dtoOptions = new DtoOptions() + .AddClientFields(User); + + var item = _libraryManager.GetPerson(name); + if (item is null) + { + return NotFound(); } - /// - /// Gets all persons. - /// - /// Optional. The maximum number of records to return. - /// The search term. - /// Optional. Specify additional fields of information to return in the output. - /// Optional. Specify additional filters to apply. - /// Optional filter by items that are marked as favorite, or not. userId is required. - /// Optional, include user data. - /// Optional, the max number of images to return, per image type. - /// Optional. The image types to include in the output. - /// Optional. If specified results will be filtered to exclude those containing the specified PersonType. Allows multiple, comma-delimited. - /// Optional. If specified results will be filtered to include only those containing the specified PersonType. Allows multiple, comma-delimited. - /// Optional. If specified, person results will be filtered on items related to said persons. - /// User id. - /// Optional, include image information in output. - /// Persons returned. - /// An containing the queryresult of persons. - [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> 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) + if (!userId.Value.Equals(default)) { - 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); - - 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 - }); - - return new QueryResult( - peopleItems - .Select(person => _dtoService.GetItemByNameDto(person, dtoOptions, null, user)) - .ToArray()); + var user = _userManager.GetUserById(userId.Value); + return _dtoService.GetBaseItemDto(item, dtoOptions, user); } - /// - /// Get person by name. - /// - /// Person name. - /// Optional. Filter by user id, and attach user data. - /// Person returned. - /// Person not found. - /// An containing the person on success, - /// or a if person not found. - [HttpGet("{name}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult GetPerson([FromRoute, Required] string name, [FromQuery] Guid? userId) - { - 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); - } + return _dtoService.GetBaseItemDto(item, dtoOptions); } } diff --git a/Jellyfin.Api/Controllers/PlaylistsController.cs b/Jellyfin.Api/Controllers/PlaylistsController.cs index e0c565da18..8d2a738d4a 100644 --- a/Jellyfin.Api/Controllers/PlaylistsController.cs +++ b/Jellyfin.Api/Controllers/PlaylistsController.cs @@ -4,8 +4,8 @@ using System.ComponentModel.DataAnnotations; using System.Linq; using System.Threading.Tasks; using Jellyfin.Api.Attributes; -using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.PlaylistDtos; using MediaBrowser.Controller.Dto; @@ -20,202 +20,204 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// Playlists controller. +/// +[Authorize] +public class PlaylistsController : BaseJellyfinApiController { + private readonly IPlaylistManager _playlistManager; + private readonly IDtoService _dtoService; + private readonly IUserManager _userManager; + private readonly ILibraryManager _libraryManager; + /// - /// Playlists controller. + /// Initializes a new instance of the class. /// - [Authorize(Policy = Policies.DefaultAuthorization)] - public class PlaylistsController : BaseJellyfinApiController + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + 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; + _dtoService = dtoService; + _playlistManager = playlistManager; + _userManager = userManager; + _libraryManager = libraryManager; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - public PlaylistsController( - IDtoService dtoService, - IPlaylistManager playlistManager, - IUserManager userManager, - ILibraryManager libraryManager) + /// + /// Creates a new playlist. + /// + /// + /// For backwards compatibility parameters can be sent via Query or Body, with Query having higher precedence. + /// Query parameters are obsolete. + /// + /// The playlist name. + /// The item ids. + /// The user id. + /// The media type. + /// The create playlist payload. + /// Playlist created. + /// + /// A that represents the asynchronous operation to create a playlist. + /// The task result contains an indicating success. + /// + [HttpPost] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> CreatePlaylist( + [FromQuery, ParameterObsolete] string? name, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder)), ParameterObsolete] IReadOnlyList ids, + [FromQuery, ParameterObsolete] Guid? userId, + [FromQuery, ParameterObsolete] string? mediaType, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] CreatePlaylistDto? createPlaylistRequest) + { + if (ids.Count == 0) { - _dtoService = dtoService; - _playlistManager = playlistManager; - _userManager = userManager; - _libraryManager = libraryManager; + ids = createPlaylistRequest?.Ids ?? Array.Empty(); } - /// - /// Creates a new playlist. - /// - /// - /// For backwards compatibility parameters can be sent via Query or Body, with Query having higher precedence. - /// Query parameters are obsolete. - /// - /// The playlist name. - /// The item ids. - /// The user id. - /// The media type. - /// The create playlist payload. - /// - /// A that represents the asynchronous operation to create a playlist. - /// The task result contains an indicating success. - /// - [HttpPost] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> CreatePlaylist( - [FromQuery, ParameterObsolete] string? name, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder)), ParameterObsolete] IReadOnlyList ids, - [FromQuery, ParameterObsolete] Guid? userId, - [FromQuery, ParameterObsolete] string? mediaType, - [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] CreatePlaylistDto? createPlaylistRequest) + userId ??= createPlaylistRequest?.UserId ?? default; + userId = RequestHelpers.GetUserId(User, userId); + var result = await _playlistManager.CreatePlaylist(new PlaylistCreationRequest { - if (ids.Count == 0) - { - ids = createPlaylistRequest?.Ids ?? Array.Empty(); - } + Name = name ?? createPlaylistRequest?.Name, + ItemIdList = ids, + UserId = userId.Value, + MediaType = mediaType ?? createPlaylistRequest?.MediaType + }).ConfigureAwait(false); - 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; + } - return result; + /// + /// Adds items to a playlist. + /// + /// The playlist id. + /// Item id, comma delimited. + /// The userId. + /// Items added to playlist. + /// An on success. + [HttpPost("{playlistId}/Items")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task 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(); + } + + /// + /// Moves a playlist item. + /// + /// The playlist id. + /// The item id. + /// The new index. + /// Item moved to new index. + /// An on success. + [HttpPost("{playlistId}/Items/{itemId}/Move/{newIndex}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task MoveItem( + [FromRoute, Required] string playlistId, + [FromRoute, Required] string itemId, + [FromRoute, Required] int newIndex) + { + await _playlistManager.MoveItemAsync(playlistId, itemId, newIndex).ConfigureAwait(false); + return NoContent(); + } + + /// + /// Removes items from a playlist. + /// + /// The playlist id. + /// The item ids, comma delimited. + /// Items removed. + /// An on success. + [HttpDelete("{playlistId}/Items")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task RemoveFromPlaylist( + [FromRoute, Required] string playlistId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] entryIds) + { + await _playlistManager.RemoveFromPlaylistAsync(playlistId, entryIds).ConfigureAwait(false); + return NoContent(); + } + + /// + /// Gets the original items of a playlist. + /// + /// The playlist id. + /// User id. + /// Optional. The record index to start at. All items with a lower index will be dropped from the results. + /// Optional. The maximum number of records to return. + /// Optional. Specify additional fields of information to return in the output. + /// Optional. Include image information in output. + /// Optional. Include user data. + /// Optional. The max number of images to return, per image type. + /// Optional. The image types to include in the output. + /// Original playlist returned. + /// Playlist not found. + /// The original playlist items. + [HttpGet("{playlistId}/Items")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult> 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(); } - /// - /// Adds items to a playlist. - /// - /// The playlist id. - /// Item id, comma delimited. - /// The userId. - /// Items added to playlist. - /// An on success. - [HttpPost("{playlistId}/Items")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task AddToPlaylist( - [FromRoute, Required] Guid playlistId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids, - [FromQuery] Guid? userId) + var user = userId.Equals(default) + ? null + : _userManager.GetUserById(userId); + + var items = playlist.GetManageableItems().ToArray(); + var count = items.Length; + if (startIndex.HasValue) { - await _playlistManager.AddToPlaylistAsync(playlistId, ids, userId ?? Guid.Empty).ConfigureAwait(false); - return NoContent(); + items = items.Skip(startIndex.Value).ToArray(); } - /// - /// Moves a playlist item. - /// - /// The playlist id. - /// The item id. - /// The new index. - /// Item moved to new index. - /// An on success. - [HttpPost("{playlistId}/Items/{itemId}/Move/{newIndex}")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task MoveItem( - [FromRoute, Required] string playlistId, - [FromRoute, Required] string itemId, - [FromRoute, Required] int newIndex) + if (limit.HasValue) { - await _playlistManager.MoveItemAsync(playlistId, itemId, newIndex).ConfigureAwait(false); - return NoContent(); + items = items.Take(limit.Value).ToArray(); } - /// - /// Removes items from a playlist. - /// - /// The playlist id. - /// The item ids, comma delimited. - /// Items removed. - /// An on success. - [HttpDelete("{playlistId}/Items")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task RemoveFromPlaylist( - [FromRoute, Required] string playlistId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] entryIds) + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + + var dtos = _dtoService.GetBaseItemDtos(items.Select(i => i.Item2).ToList(), dtoOptions, user); + for (int index = 0; index < dtos.Count; index++) { - await _playlistManager.RemoveFromPlaylistAsync(playlistId, entryIds).ConfigureAwait(false); - return NoContent(); + dtos[index].PlaylistItemId = items[index].Item1.Id; } - /// - /// Gets the original items of a playlist. - /// - /// The playlist id. - /// User id. - /// Optional. The record index to start at. All items with a lower index will be dropped from the results. - /// Optional. The maximum number of records to return. - /// Optional. Specify additional fields of information to return in the output. - /// Optional. Include image information in output. - /// Optional. Include user data. - /// Optional. The max number of images to return, per image type. - /// Optional. The image types to include in the output. - /// Original playlist returned. - /// Playlist not found. - /// The original playlist items. - [HttpGet("{playlistId}/Items")] - public ActionResult> 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 result = new QueryResult( + startIndex, + count, + dtos); - var user = userId.Equals(default) - ? null - : _userManager.GetUserById(userId); - - var items = playlist.GetManageableItems().ToArray(); - - var count = items.Length; - - if (startIndex.HasValue) - { - items = items.Skip(startIndex.Value).ToArray(); - } - - if (limit.HasValue) - { - items = items.Take(limit.Value).ToArray(); - } - - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - - var dtos = _dtoService.GetBaseItemDtos(items.Select(i => i.Item2).ToList(), dtoOptions, user); - - for (int index = 0; index < dtos.Count; index++) - { - dtos[index].PlaylistItemId = items[index].Item1.Id; - } - - var result = new QueryResult( - startIndex, - count, - dtos); - - return result; - } + return result; } } diff --git a/Jellyfin.Api/Controllers/PlaystateController.cs b/Jellyfin.Api/Controllers/PlaystateController.cs index 58f9b7d356..8ad553bcb8 100644 --- a/Jellyfin.Api/Controllers/PlaystateController.cs +++ b/Jellyfin.Api/Controllers/PlaystateController.cs @@ -2,11 +2,11 @@ using System; using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; -using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Entities; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Session; using MediaBrowser.Model.Dto; @@ -16,350 +16,385 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// Playstate controller. +/// +[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 _logger; + private readonly TranscodingJobHelper _transcodingJobHelper; + /// - /// Playstate controller. + /// Initializes a new instance of the class. /// - [Route("")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class PlaystateController : BaseJellyfinApiController + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Th singleton. + 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 _logger; - private readonly TranscodingJobHelper _transcodingJobHelper; + _userManager = userManager; + _userDataRepository = userDataRepository; + _libraryManager = libraryManager; + _sessionManager = sessionManager; + _logger = loggerFactory.CreateLogger(); - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Th singleton. - public PlaystateController( - IUserManager userManager, - IUserDataManager userDataRepository, - ILibraryManager libraryManager, - ISessionManager sessionManager, - ILoggerFactory loggerFactory, - TranscodingJobHelper transcodingJobHelper) + _transcodingJobHelper = transcodingJobHelper; + } + + /// + /// Marks an item as played for user. + /// + /// User id. + /// Item id. + /// Optional. The date the item was played. + /// Item marked as played. + /// Item not found. + /// An containing the , or a if item was not found. + [HttpPost("Users/{userId}/PlayedItems/{itemId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> MarkPlayedItem( + [FromRoute, Required] Guid userId, + [FromRoute, Required] Guid itemId, + [FromQuery, ModelBinder(typeof(LegacyDateTimeModelBinder))] DateTime? datePlayed) + { + var user = _userManager.GetUserById(userId); + if (user is null) { - _userManager = userManager; - _userDataRepository = userDataRepository; - _libraryManager = libraryManager; - _sessionManager = sessionManager; - _logger = loggerFactory.CreateLogger(); - - _transcodingJobHelper = transcodingJobHelper; + return NotFound(); } - /// - /// Marks an item as played for user. - /// - /// User id. - /// Item id. - /// Optional. The date the item was played. - /// Item marked as played. - /// An containing the . - [HttpPost("Users/{userId}/PlayedItems/{itemId}")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> MarkPlayedItem( - [FromRoute, Required] Guid userId, - [FromRoute, Required] Guid itemId, - [FromQuery, ModelBinder(typeof(LegacyDateTimeModelBinder))] DateTime? datePlayed) + var session = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + + var item = _libraryManager.GetItemById(itemId); + if (item 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) + return NotFound(); + } + + var dto = UpdatePlayedStatus(user, item, true, datePlayed); + 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, true, datePlayed); + return NotFound(); } - return dto; + UpdatePlayedStatus(additionalUser, item, true, datePlayed); } - /// - /// Marks an item as unplayed for user. - /// - /// User id. - /// Item id. - /// Item marked as unplayed. - /// A containing the . - [HttpDelete("Users/{userId}/PlayedItems/{itemId}")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> MarkUnplayedItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) + return dto; + } + + /// + /// Marks an item as unplayed for user. + /// + /// User id. + /// Item id. + /// Item marked as unplayed. + /// Item not found. + /// A containing the , or a if item was not found. + [HttpDelete("Users/{userId}/PlayedItems/{itemId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> MarkUnplayedItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) + { + 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, false, null); - foreach (var additionalUserInfo in session.AdditionalUsers) + return NotFound(); + } + + var session = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var item = _libraryManager.GetItemById(itemId); + + if (item is null) + { + return NotFound(); + } + + var dto = UpdatePlayedStatus(user, item, 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, false, null); } - /// - /// Reports playback has started within a session. - /// - /// The playback start info. - /// Playback start recorded. - /// A . - [HttpPost("Sessions/Playing")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task ReportPlaybackStart([FromBody] PlaybackStartInfo playbackStartInfo) + return dto; + } + + /// + /// Reports playback has started within a session. + /// + /// The playback start info. + /// Playback start recorded. + /// A . + [HttpPost("Sessions/Playing")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task 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(); + } + + /// + /// Reports playback progress within a session. + /// + /// The playback progress info. + /// Playback progress recorded. + /// A . + [HttpPost("Sessions/Playing/Progress")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task 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(); + } + + /// + /// Pings a playback session. + /// + /// Playback session id. + /// Playback session pinged. + /// A . + [HttpPost("Sessions/Playing/Ping")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult PingPlaybackSession([FromQuery, Required] string playSessionId) + { + _transcodingJobHelper.PingTranscodingJob(playSessionId, null); + return NoContent(); + } + + /// + /// Reports playback has stopped within a session. + /// + /// The playback stop info. + /// Playback stop recorded. + /// A . + [HttpPost("Sessions/Playing/Stopped")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task ReportPlaybackStopped([FromBody] PlaybackStopInfo playbackStopInfo) + { + _logger.LogDebug("ReportPlaybackStopped PlaySessionId: {0}", playbackStopInfo.PlaySessionId ?? string.Empty); + if (!string.IsNullOrWhiteSpace(playbackStopInfo.PlaySessionId)) { - 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); } - /// - /// Reports playback progress within a session. - /// - /// The playback progress info. - /// Playback progress recorded. - /// A . - [HttpPost("Sessions/Playing/Progress")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task ReportPlaybackProgress([FromBody] PlaybackProgressInfo playbackProgressInfo) + playbackStopInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + await _sessionManager.OnPlaybackStopped(playbackStopInfo).ConfigureAwait(false); + return NoContent(); + } + + /// + /// Reports that a user has begun playing an item. + /// + /// User id. + /// Item id. + /// The id of the MediaSource. + /// The audio stream index. + /// The subtitle stream index. + /// The play method. + /// The live stream id. + /// The play session id. + /// Indicates if the client can seek. + /// Play start recorded. + /// A . + [HttpPost("Users/{userId}/PlayingItems/{itemId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")] + public async Task 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 { - 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(); + } + + /// + /// Reports a user's playback progress. + /// + /// User id. + /// Item id. + /// The id of the MediaSource. + /// Optional. The current position, in ticks. 1 tick = 10000 ms. + /// The audio stream index. + /// The subtitle stream index. + /// Scale of 0-100. + /// The play method. + /// The live stream id. + /// The play session id. + /// The repeat mode. + /// Indicates if the player is paused. + /// Indicates if the player is muted. + /// Play progress recorded. + /// A . + [HttpPost("Users/{userId}/PlayingItems/{itemId}/Progress")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")] + public async Task 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 + { + 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(); + } + + /// + /// Reports that a user has stopped playing an item. + /// + /// User id. + /// Item id. + /// The id of the MediaSource. + /// The next media type that will play. + /// Optional. The position, in ticks, where playback stopped. 1 tick = 10000 ms. + /// The live stream id. + /// The play session id. + /// Playback stop recorded. + /// A . + [HttpDelete("Users/{userId}/PlayingItems/{itemId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")] + public async Task 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 + }; + + _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); } - /// - /// Pings a playback session. - /// - /// Playback session id. - /// Playback session pinged. - /// A . - [HttpPost("Sessions/Playing/Ping")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult PingPlaybackSession([FromQuery, Required] string playSessionId) + playbackStopInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + await _sessionManager.OnPlaybackStopped(playbackStopInfo).ConfigureAwait(false); + return NoContent(); + } + + /// + /// Updates the played status. + /// + /// The user. + /// The item. + /// if set to true [was played]. + /// The date played. + /// Task. + private UserItemDataDto UpdatePlayedStatus(User user, BaseItem item, bool wasPlayed, DateTime? datePlayed) + { + if (wasPlayed) { - _transcodingJobHelper.PingTranscodingJob(playSessionId, null); - return NoContent(); + item.MarkPlayed(user, datePlayed, true); + } + else + { + item.MarkUnplayed(user); } - /// - /// Reports playback has stopped within a session. - /// - /// The playback stop info. - /// Playback stop recorded. - /// A . - [HttpPost("Sessions/Playing/Stopped")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task ReportPlaybackStopped([FromBody] PlaybackStopInfo playbackStopInfo) + return _userDataRepository.GetUserDataDto(item, user); + } + + private PlayMethod ValidatePlayMethod(PlayMethod method, string? playSessionId) + { + if (method == PlayMethod.Transcode) { - _logger.LogDebug("ReportPlaybackStopped PlaySessionId: {0}", playbackStopInfo.PlaySessionId ?? string.Empty); - if (!string.IsNullOrWhiteSpace(playbackStopInfo.PlaySessionId)) + var job = string.IsNullOrWhiteSpace(playSessionId) ? null : _transcodingJobHelper.GetTranscodingJob(playSessionId); + if (job is null) { - await _transcodingJobHelper.KillTranscodingJobs(User.GetDeviceId()!, playbackStopInfo.PlaySessionId, s => true).ConfigureAwait(false); + return PlayMethod.DirectPlay; } - - playbackStopInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - await _sessionManager.OnPlaybackStopped(playbackStopInfo).ConfigureAwait(false); - return NoContent(); } - /// - /// Reports that a user has begun playing an item. - /// - /// User id. - /// Item id. - /// The id of the MediaSource. - /// The audio stream index. - /// The subtitle stream index. - /// The play method. - /// The live stream id. - /// The play session id. - /// Indicates if the client can seek. - /// Play start recorded. - /// A . - [HttpPost("Users/{userId}/PlayingItems/{itemId}")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")] - public async Task 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 - { - 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(); - } - - /// - /// Reports a user's playback progress. - /// - /// User id. - /// Item id. - /// The id of the MediaSource. - /// Optional. The current position, in ticks. 1 tick = 10000 ms. - /// The audio stream index. - /// The subtitle stream index. - /// Scale of 0-100. - /// The play method. - /// The live stream id. - /// The play session id. - /// The repeat mode. - /// Indicates if the player is paused. - /// Indicates if the player is muted. - /// Play progress recorded. - /// A . - [HttpPost("Users/{userId}/PlayingItems/{itemId}/Progress")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")] - public async Task 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 - { - 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(); - } - - /// - /// Reports that a user has stopped playing an item. - /// - /// User id. - /// Item id. - /// The id of the MediaSource. - /// The next media type that will play. - /// Optional. The position, in ticks, where playback stopped. 1 tick = 10000 ms. - /// The live stream id. - /// The play session id. - /// Playback stop recorded. - /// A . - [HttpDelete("Users/{userId}/PlayingItems/{itemId}")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")] - public async Task 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 - }; - - _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); - } - - playbackStopInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - await _sessionManager.OnPlaybackStopped(playbackStopInfo).ConfigureAwait(false); - return NoContent(); - } - - /// - /// Updates the played status. - /// - /// The user. - /// The item id. - /// if set to true [was played]. - /// The date played. - /// Task. - private UserItemDataDto UpdatePlayedStatus(User user, Guid itemId, bool wasPlayed, DateTime? datePlayed) - { - var item = _libraryManager.GetItemById(itemId); - - if (wasPlayed) - { - item.MarkPlayed(user, datePlayed, true); - } - else - { - item.MarkUnplayed(user); - } - - return _userDataRepository.GetUserDataDto(item, user); - } - - private PlayMethod ValidatePlayMethod(PlayMethod method, string? playSessionId) - { - if (method == PlayMethod.Transcode) - { - var job = string.IsNullOrWhiteSpace(playSessionId) ? null : _transcodingJobHelper.GetTranscodingJob(playSessionId); - if (job is null) - { - return PlayMethod.DirectPlay; - } - } - - return method; - } + return method; } } diff --git a/Jellyfin.Api/Controllers/PluginsController.cs b/Jellyfin.Api/Controllers/PluginsController.cs index b8a09990a5..72ad14a281 100644 --- a/Jellyfin.Api/Controllers/PluginsController.cs +++ b/Jellyfin.Api/Controllers/PluginsController.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Text.Json; @@ -17,250 +16,249 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// Plugins controller. +/// +[Authorize] +public class PluginsController : BaseJellyfinApiController { + private readonly IInstallationManager _installationManager; + private readonly IPluginManager _pluginManager; + private readonly JsonSerializerOptions _serializerOptions; + /// - /// Plugins controller. + /// Initializes a new instance of the class. /// - [Authorize(Policy = Policies.DefaultAuthorization)] - public class PluginsController : BaseJellyfinApiController + /// Instance of the interface. + /// Instance of the interface. + 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; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - public PluginsController( - IInstallationManager installationManager, - IPluginManager pluginManager) + /// + /// Gets a list of currently installed plugins. + /// + /// Installed plugins returned. + /// List of currently installed plugins. + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> GetPlugins() + { + return Ok(_pluginManager.Plugins + .OrderBy(p => p.Name) + .Select(p => p.GetPluginInfo())); + } + + /// + /// Enables a disabled plugin. + /// + /// Plugin id. + /// Plugin version. + /// Plugin enabled. + /// Plugin not found. + /// An on success, or a if the plugin could not be found. + [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(); } - /// - /// Gets a list of currently installed plugins. - /// - /// Installed plugins returned. - /// List of currently installed plugins. - [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> GetPlugins() + _pluginManager.EnablePlugin(plugin); + return NoContent(); + } + + /// + /// Disable a plugin. + /// + /// Plugin id. + /// Plugin version. + /// Plugin disabled. + /// Plugin not found. + /// An on success, or a if the plugin could not be found. + [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(); } - /// - /// Enables a disabled plugin. - /// - /// Plugin id. - /// Plugin version. - /// Plugin enabled. - /// Plugin not found. - /// An on success, or a if the plugin could not be found. - [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(); + /// + /// Uninstalls a plugin by version. + /// + /// Plugin id. + /// Plugin version. + /// Plugin uninstalled. + /// Plugin not found. + /// An on success, or a if the plugin could not be found. + [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(); } - /// - /// Disable a plugin. - /// - /// Plugin id. - /// Plugin version. - /// Plugin disabled. - /// Plugin not found. - /// An on success, or a if the plugin could not be found. - [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) + _installationManager.UninstallPlugin(plugin); + return NoContent(); + } + + /// + /// Uninstalls a plugin. + /// + /// Plugin id. + /// Plugin uninstalled. + /// Plugin not found. + /// An on success, or a if the plugin could not be found. + [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.MinBy(p => p.Manifest.Status); + + if (plugin is not null) { - var plugin = _pluginManager.GetPlugin(pluginId, version); - if (plugin is null) - { - return NotFound(); - } - - _pluginManager.DisablePlugin(plugin); - return NoContent(); - } - - /// - /// Uninstalls a plugin by version. - /// - /// Plugin id. - /// Plugin version. - /// Plugin uninstalled. - /// Plugin not found. - /// An on success, or a if the plugin could not be found. - [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(); - } - _installationManager.UninstallPlugin(plugin); return NoContent(); } - /// - /// Uninstalls a plugin. - /// - /// Plugin id. - /// Plugin uninstalled. - /// Plugin not found. - /// An on success, or a if the plugin could not be found. - [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) + return NotFound(); + } + + /// + /// Gets plugin configuration. + /// + /// Plugin id. + /// Plugin configuration returned. + /// Plugin not found or plugin configuration not found. + /// Plugin configuration. + [HttpGet("{pluginId}/Configuration")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult GetPluginConfiguration([FromRoute, Required] Guid pluginId) + { + var plugin = _pluginManager.GetPlugin(pluginId); + if (plugin?.Instance is IHasPluginConfiguration configPlugin) { - // If no version is given, return the current instance. - var plugins = _pluginManager.Plugins.Where(p => p.Id.Equals(pluginId)).ToList(); + return configPlugin.Configuration; + } - // 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(); + } + /// + /// Updates plugin configuration. + /// + /// + /// Accepts plugin configuration as JSON body. + /// + /// Plugin id. + /// Plugin configuration updated. + /// Plugin not found or plugin does not have configuration. + /// An on success, or a if the plugin could not be found. + [HttpPost("{pluginId}/Configuration")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UpdatePluginConfiguration([FromRoute, Required] Guid pluginId) + { + var plugin = _pluginManager.GetPlugin(pluginId); + if (plugin?.Instance is not IHasPluginConfiguration configPlugin) + { return NotFound(); } - /// - /// Gets plugin configuration. - /// - /// Plugin id. - /// Plugin configuration returned. - /// Plugin not found or plugin configuration not found. - /// Plugin configuration. - [HttpGet("{pluginId}/Configuration")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult GetPluginConfiguration([FromRoute, Required] Guid pluginId) - { - var plugin = _pluginManager.GetPlugin(pluginId); - if (plugin?.Instance is IHasPluginConfiguration configPlugin) - { - return configPlugin.Configuration; - } + var configuration = (BasePluginConfiguration?)await JsonSerializer.DeserializeAsync(Request.Body, configPlugin.ConfigurationType, _serializerOptions) + .ConfigureAwait(false); + if (configuration is not null) + { + configPlugin.UpdateConfiguration(configuration); + } + + return NoContent(); + } + + /// + /// Gets a plugin's image. + /// + /// Plugin id. + /// Plugin version. + /// Plugin image returned. + /// Plugin's image. + [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(); } - /// - /// Updates plugin configuration. - /// - /// - /// Accepts plugin configuration as JSON body. - /// - /// Plugin id. - /// Plugin configuration updated. - /// Plugin not found or plugin does not have configuration. - /// An on success, or a if the plugin could not be found. - [HttpPost("{pluginId}/Configuration")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task UpdatePluginConfiguration([FromRoute, Required] Guid pluginId) + 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); - if (plugin?.Instance is not IHasPluginConfiguration configPlugin) - { - return NotFound(); - } - - var configuration = (BasePluginConfiguration?)await JsonSerializer.DeserializeAsync(Request.Body, configPlugin.ConfigurationType, _serializerOptions) - .ConfigureAwait(false); - - if (configuration is not null) - { - configPlugin.UpdateConfiguration(configuration); - } - - return NoContent(); - } - - /// - /// Gets a plugin's image. - /// - /// Plugin id. - /// Plugin version. - /// Plugin image returned. - /// Plugin's image. - [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(); - } - - 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)); - } - - /// - /// Gets a plugin's manifest. - /// - /// Plugin id. - /// Plugin manifest returned. - /// Plugin not found. - /// A on success, or a if the plugin could not be found. - [HttpPost("{pluginId}/Manifest")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult GetPluginManifest([FromRoute, Required] Guid pluginId) - { - var plugin = _pluginManager.GetPlugin(pluginId); - - if (plugin is not null) - { - return plugin.Manifest; - } - return NotFound(); } + + imagePath = Path.Combine(plugin.Path, plugin.Manifest.ImagePath); + return PhysicalFile(imagePath, MimeTypes.GetMimeType(imagePath)); + } + + /// + /// Gets a plugin's manifest. + /// + /// Plugin id. + /// Plugin manifest returned. + /// Plugin not found. + /// A on success, or a if the plugin could not be found. + [HttpPost("{pluginId}/Manifest")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult GetPluginManifest([FromRoute, Required] Guid pluginId) + { + var plugin = _pluginManager.GetPlugin(pluginId); + + if (plugin is not null) + { + return plugin.Manifest; + } + + return NotFound(); } } diff --git a/Jellyfin.Api/Controllers/QuickConnectController.cs b/Jellyfin.Api/Controllers/QuickConnectController.cs index 6dbcdae228..14f5265aa7 100644 --- a/Jellyfin.Api/Controllers/QuickConnectController.cs +++ b/Jellyfin.Api/Controllers/QuickConnectController.cs @@ -1,8 +1,6 @@ using System; using System.ComponentModel.DataAnnotations; using System.Threading.Tasks; -using Jellyfin.Api.Constants; -using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Authentication; @@ -13,126 +11,119 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// Quick connect controller. +/// +public class QuickConnectController : BaseJellyfinApiController { + private readonly IQuickConnect _quickConnect; + private readonly IAuthorizationContext _authContext; + /// - /// Quick connect controller. + /// Initializes a new instance of the class. /// - public class QuickConnectController : BaseJellyfinApiController + /// Instance of the interface. + /// Instance of the interface. + public QuickConnectController(IQuickConnect quickConnect, IAuthorizationContext authContext) { - private readonly IQuickConnect _quickConnect; - private readonly IAuthorizationContext _authContext; + _quickConnect = quickConnect; + _authContext = authContext; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - public QuickConnectController(IQuickConnect quickConnect, IAuthorizationContext authContext) + /// + /// Gets the current quick connect state. + /// + /// Quick connect state returned. + /// Whether Quick Connect is enabled on the server or not. + [HttpGet("Enabled")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult GetQuickConnectEnabled() + { + return _quickConnect.IsEnabled; + } + + /// + /// Initiate a new quick connect request. + /// + /// Quick connect request successfully created. + /// Quick connect is not active on this server. + /// A with a secret and code for future use or an error message. + [HttpPost("Initiate")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> InitiateQuickConnect() + { + try { - _quickConnect = quickConnect; - _authContext = authContext; + var auth = await _authContext.GetAuthorizationInfo(Request).ConfigureAwait(false); + return _quickConnect.TryConnect(auth); } - - /// - /// Gets the current quick connect state. - /// - /// Quick connect state returned. - /// Whether Quick Connect is enabled on the server or not. - [HttpGet("Enabled")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult GetQuickConnectEnabled() + catch (AuthenticationException) { - return _quickConnect.IsEnabled; + return Unauthorized("Quick connect is disabled"); } + } - /// - /// Initiate a new quick connect request. - /// - /// Quick connect request successfully created. - /// Quick connect is not active on this server. - /// A with a secret and code for future use or an error message. - [HttpPost("Initiate")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> InitiateQuickConnect() + /// + /// Old version of using a GET method. + /// Still available to avoid breaking compatibility. + /// + /// The result of . + [Obsolete("Use POST request instead")] + [HttpGet("Initiate")] + [ApiExplorerSettings(IgnoreApi = true)] + public Task> InitiateQuickConnectLegacy() => InitiateQuickConnect(); + + /// + /// Attempts to retrieve authentication information. + /// + /// Secret previously returned from the Initiate endpoint. + /// Quick connect result returned. + /// Unknown quick connect secret. + /// An updated . + [HttpGet("Connect")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult GetQuickConnectState([FromQuery, Required] string secret) + { + try { - try - { - var auth = await _authContext.GetAuthorizationInfo(Request).ConfigureAwait(false); - return _quickConnect.TryConnect(auth); - } - catch (AuthenticationException) - { - return Unauthorized("Quick connect is disabled"); - } + return _quickConnect.CheckRequestStatus(secret); } - - /// - /// Old version of using a GET method. - /// Still available to avoid breaking compatibility. - /// - /// The result of . - [Obsolete("Use POST request instead")] - [HttpGet("Initiate")] - [ApiExplorerSettings(IgnoreApi = true)] - public Task> InitiateQuickConnectLegacy() => InitiateQuickConnect(); - - /// - /// Attempts to retrieve authentication information. - /// - /// Secret previously returned from the Initiate endpoint. - /// Quick connect result returned. - /// Unknown quick connect secret. - /// An updated . - [HttpGet("Connect")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult GetQuickConnectState([FromQuery, Required] string secret) + catch (ResourceNotFoundException) { - try - { - return _quickConnect.CheckRequestStatus(secret); - } - catch (ResourceNotFoundException) - { - return NotFound("Unknown secret"); - } - catch (AuthenticationException) - { - return Unauthorized("Quick connect is disabled"); - } + return NotFound("Unknown secret"); } - - /// - /// Authorizes a pending quick connect request. - /// - /// Quick connect code to authorize. - /// The user the authorize. Access to the requested user is required. - /// Quick connect result authorized successfully. - /// Unknown user id. - /// Boolean indicating if the authorization was successful. - [HttpPost("Authorize")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - public async Task> AuthorizeQuickConnect([FromQuery, Required] string code, [FromQuery] Guid? userId = null) + 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"); - } + /// + /// Authorizes a pending quick connect request. + /// + /// Quick connect code to authorize. + /// The user the authorize. Access to the requested user is required. + /// Quick connect result authorized successfully. + /// Unknown user id. + /// Boolean indicating if the authorization was successful. + [HttpPost("Authorize")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> AuthorizeQuickConnect([FromQuery, Required] string code, [FromQuery] Guid? userId = null) + { + userId = RequestHelpers.GetUserId(User, userId); - try - { - return await _quickConnect.AuthorizeRequest(actualUserId, code).ConfigureAwait(false); - } - catch (AuthenticationException) - { - return Unauthorized("Quick connect is disabled"); - } + try + { + return await _quickConnect.AuthorizeRequest(userId.Value, code).ConfigureAwait(false); + } + catch (AuthenticationException) + { + return Unauthorized("Quick connect is disabled"); } } } diff --git a/Jellyfin.Api/Controllers/RemoteImageController.cs b/Jellyfin.Api/Controllers/RemoteImageController.cs index da9e8cf90d..5c77db2407 100644 --- a/Jellyfin.Api/Controllers/RemoteImageController.cs +++ b/Jellyfin.Api/Controllers/RemoteImageController.cs @@ -15,165 +15,164 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// Remote Images Controller. +/// +[Route("")] +public class RemoteImageController : BaseJellyfinApiController { + private readonly IProviderManager _providerManager; + private readonly IServerApplicationPaths _applicationPaths; + private readonly ILibraryManager _libraryManager; + /// - /// Remote Images Controller. + /// Initializes a new instance of the class. /// - [Route("")] - public class RemoteImageController : BaseJellyfinApiController + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + public RemoteImageController( + IProviderManager providerManager, + IServerApplicationPaths applicationPaths, + ILibraryManager libraryManager) { - private readonly IProviderManager _providerManager; - private readonly IServerApplicationPaths _applicationPaths; - private readonly ILibraryManager _libraryManager; + _providerManager = providerManager; + _applicationPaths = applicationPaths; + _libraryManager = libraryManager; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - public RemoteImageController( - IProviderManager providerManager, - IServerApplicationPaths applicationPaths, - ILibraryManager libraryManager) + /// + /// Gets available remote images for an item. + /// + /// Item Id. + /// The image type. + /// Optional. The record index to start at. All items with a lower index will be dropped from the results. + /// Optional. The maximum number of records to return. + /// Optional. The image provider to use. + /// Optional. Include all languages. + /// Remote Images returned. + /// Item not found. + /// Remote Image Result. + [HttpGet("Items/{itemId}/RemoteImages")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetRemoteImages( + [FromRoute, Required] Guid itemId, + [FromQuery] ImageType? type, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] string? providerName, + [FromQuery] bool includeAllLanguages = false) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) { - _providerManager = providerManager; - _applicationPaths = applicationPaths; - _libraryManager = libraryManager; + return NotFound(); } - /// - /// Gets available remote images for an item. - /// - /// Item Id. - /// The image type. - /// Optional. The record index to start at. All items with a lower index will be dropped from the results. - /// Optional. The maximum number of records to return. - /// Optional. The image provider to use. - /// Optional. Include all languages. - /// Remote Images returned. - /// Item not found. - /// Remote Image Result. - [HttpGet("Items/{itemId}/RemoteImages")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> 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)); } - /// - /// Gets available remote image providers for an item. - /// - /// Item Id. - /// Returned remote image providers. - /// Item not found. - /// List of remote image providers. - [HttpGet("Items/{itemId}/RemoteImages/Providers")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult> 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(); } - /// - /// Downloads a remote image for an item. - /// - /// Item Id. - /// The image type. - /// The image url. - /// Remote image downloaded. - /// Remote image not found. - /// Download status. - [HttpPost("Items/{itemId}/RemoteImages/Download")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task 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(); - } - - await _providerManager.SaveImage(item, imageUrl, type, null, CancellationToken.None) - .ConfigureAwait(false); - - await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false); - return NoContent(); + imageArray = imageArray.Take(limit.Value).ToArray(); } - /// - /// Gets the full cache path. - /// - /// The filename. - /// System.String. - private string GetFullCachePath(string filename) + result.Images = imageArray; + return result; + } + + /// + /// Gets available remote image providers for an item. + /// + /// Item Id. + /// Returned remote image providers. + /// Item not found. + /// List of remote image providers. + [HttpGet("Items/{itemId}/RemoteImages/Providers")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult> GetRemoteImageProviders([FromRoute, Required] Guid itemId) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) { - return Path.Combine(_applicationPaths.CachePath, "remote-images", filename.Substring(0, 1), filename); + return NotFound(); } + + return Ok(_providerManager.GetRemoteImageProviderInfo(item)); + } + + /// + /// Downloads a remote image for an item. + /// + /// Item Id. + /// The image type. + /// The image url. + /// Remote image downloaded. + /// Remote image not found. + /// Download status. + [HttpPost("Items/{itemId}/RemoteImages/Download")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DownloadRemoteImage( + [FromRoute, Required] Guid itemId, + [FromQuery, Required] ImageType type, + [FromQuery] string? imageUrl) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + return NotFound(); + } + + await _providerManager.SaveImage(item, imageUrl, type, null, CancellationToken.None) + .ConfigureAwait(false); + + await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false); + return NoContent(); + } + + /// + /// Gets the full cache path. + /// + /// The filename. + /// System.String. + private string GetFullCachePath(string filename) + { + return Path.Combine(_applicationPaths.CachePath, "remote-images", filename.Substring(0, 1), filename); } } diff --git a/Jellyfin.Api/Controllers/ScheduledTasksController.cs b/Jellyfin.Api/Controllers/ScheduledTasksController.cs index 832e145050..c8fa11ac62 100644 --- a/Jellyfin.Api/Controllers/ScheduledTasksController.cs +++ b/Jellyfin.Api/Controllers/ScheduledTasksController.cs @@ -8,154 +8,153 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// Scheduled Tasks Controller. +/// +[Authorize(Policy = Policies.RequiresElevation)] +public class ScheduledTasksController : BaseJellyfinApiController { + private readonly ITaskManager _taskManager; + /// - /// Scheduled Tasks Controller. + /// Initializes a new instance of the class. /// - [Authorize(Policy = Policies.RequiresElevation)] - public class ScheduledTasksController : BaseJellyfinApiController + /// Instance of the interface. + public ScheduledTasksController(ITaskManager taskManager) { - private readonly ITaskManager _taskManager; + _taskManager = taskManager; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - public ScheduledTasksController(ITaskManager taskManager) + /// + /// Get tasks. + /// + /// Optional filter tasks that are hidden, or not. + /// Optional filter tasks that are enabled, or not. + /// Scheduled tasks retrieved. + /// The list of scheduled tasks. + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public IEnumerable GetTasks( + [FromQuery] bool? isHidden, + [FromQuery] bool? isEnabled) + { + IEnumerable tasks = _taskManager.ScheduledTasks.OrderBy(o => o.Name); + + foreach (var task in tasks) { - _taskManager = taskManager; - } - - /// - /// Get tasks. - /// - /// Optional filter tasks that are hidden, or not. - /// Optional filter tasks that are enabled, or not. - /// Scheduled tasks retrieved. - /// The list of scheduled tasks. - [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK)] - public IEnumerable GetTasks( - [FromQuery] bool? isHidden, - [FromQuery] bool? isEnabled) - { - IEnumerable 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); - } - } - - /// - /// Get task by id. - /// - /// Task Id. - /// Task retrieved. - /// Task not found. - /// An containing the task on success, or a if the task could not be found. - [HttpGet("{taskId}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult GetTask([FromRoute, Required] string taskId) - { - var task = _taskManager.ScheduledTasks.FirstOrDefault(i => - string.Equals(i.Id, taskId, StringComparison.OrdinalIgnoreCase)); - - if (task is null) - { - return NotFound(); + if (isEnabled.HasValue && isEnabled.Value != scheduledTask.IsEnabled) + { + continue; + } } - return ScheduledTaskHelpers.GetTaskInfo(task); - } - - /// - /// Start specified task. - /// - /// Task Id. - /// Task started. - /// Task not found. - /// An on success, or a if the file could not be found. - [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)); - - if (task is null) - { - return NotFound(); - } - - _taskManager.Execute(task, new TaskOptions()); - return NoContent(); - } - - /// - /// Stop specified task. - /// - /// Task Id. - /// Task stopped. - /// Task not found. - /// An on success, or a if the file could not be found. - [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)); - - if (task is null) - { - return NotFound(); - } - - _taskManager.Cancel(task); - return NoContent(); - } - - /// - /// Update specified task triggers. - /// - /// Task Id. - /// Triggers. - /// Task triggers updated. - /// Task not found. - /// An on success, or a if the file could not be found. - [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(); + yield return ScheduledTaskHelpers.GetTaskInfo(task); } } + + /// + /// Get task by id. + /// + /// Task Id. + /// Task retrieved. + /// Task not found. + /// An containing the task on success, or a if the task could not be found. + [HttpGet("{taskId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult GetTask([FromRoute, Required] string taskId) + { + var task = _taskManager.ScheduledTasks.FirstOrDefault(i => + string.Equals(i.Id, taskId, StringComparison.OrdinalIgnoreCase)); + + if (task is null) + { + return NotFound(); + } + + return ScheduledTaskHelpers.GetTaskInfo(task); + } + + /// + /// Start specified task. + /// + /// Task Id. + /// Task started. + /// Task not found. + /// An on success, or a if the file could not be found. + [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)); + + if (task is null) + { + return NotFound(); + } + + _taskManager.Execute(task, new TaskOptions()); + return NoContent(); + } + + /// + /// Stop specified task. + /// + /// Task Id. + /// Task stopped. + /// Task not found. + /// An on success, or a if the file could not be found. + [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)); + + if (task is null) + { + return NotFound(); + } + + _taskManager.Cancel(task); + return NoContent(); + } + + /// + /// Update specified task triggers. + /// + /// Task Id. + /// Triggers. + /// Task triggers updated. + /// Task not found. + /// An on success, or a if the file could not be found. + [HttpPost("{taskId}/Triggers")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult UpdateTask( + [FromRoute, Required] string taskId, + [FromBody, Required] TaskTriggerInfo[] triggerInfos) + { + var task = _taskManager.ScheduledTasks.FirstOrDefault(o => + o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase)); + if (task is null) + { + return NotFound(); + } + + task.Triggers = triggerInfos; + return NoContent(); + } } diff --git a/Jellyfin.Api/Controllers/SearchController.cs b/Jellyfin.Api/Controllers/SearchController.cs index 3b7719f373..387b3ea5a6 100644 --- a/Jellyfin.Api/Controllers/SearchController.cs +++ b/Jellyfin.Api/Controllers/SearchController.cs @@ -3,7 +3,7 @@ using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Globalization; using System.Linq; -using Jellyfin.Api.Constants; +using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; using Jellyfin.Extensions; @@ -20,247 +20,247 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// Search controller. +/// +[Route("Search/Hints")] +[Authorize] +public class SearchController : BaseJellyfinApiController { + private readonly ISearchEngine _searchEngine; + private readonly ILibraryManager _libraryManager; + private readonly IDtoService _dtoService; + private readonly IImageProcessor _imageProcessor; + /// - /// Search controller. + /// Initializes a new instance of the class. /// - [Route("Search/Hints")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class SearchController : BaseJellyfinApiController + /// Instance of interface. + /// Instance of interface. + /// Instance of interface. + /// Instance of interface. + 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; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of interface. - /// Instance of interface. - /// Instance of interface. - /// Instance of interface. - public SearchController( - ISearchEngine searchEngine, - ILibraryManager libraryManager, - IDtoService dtoService, - IImageProcessor imageProcessor) + /// + /// Gets the search hint result. + /// + /// Optional. The record index to start at. All items with a lower index will be dropped from the results. + /// Optional. The maximum number of records to return. + /// Optional. Supply a user id to search within a user's library or omit to search all. + /// The search term to filter on. + /// If specified, only results with the specified item types are returned. This allows multiple, comma delimited. + /// If specified, results with these item types are filtered out. This allows multiple, comma delimited. + /// If specified, only results with the specified media types are returned. This allows multiple, comma delimited. + /// If specified, only children of the parent are returned. + /// Optional filter for movies. + /// Optional filter for series. + /// Optional filter for news. + /// Optional filter for kids. + /// Optional filter for sports. + /// Optional filter whether to include people. + /// Optional filter whether to include media. + /// Optional filter whether to include genres. + /// Optional filter whether to include studios. + /// Optional filter whether to include artists. + /// Search hint returned. + /// An with the results of the search. + [HttpGet] + [Description("Gets search hints based on a search term")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult 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, - /// - /// Gets the search hint result. - /// - /// Optional. The record index to start at. All items with a lower index will be dropped from the results. - /// Optional. The maximum number of records to return. - /// Optional. Supply a user id to search within a user's library or omit to search all. - /// The search term to filter on. - /// If specified, only results with the specified item types are returned. This allows multiple, comma delimited. - /// If specified, results with these item types are filtered out. This allows multiple, comma delimited. - /// If specified, only results with the specified media types are returned. This allows multiple, comma delimited. - /// If specified, only children of the parent are returned. - /// Optional filter for movies. - /// Optional filter for series. - /// Optional filter for news. - /// Optional filter for kids. - /// Optional filter for sports. - /// Optional filter whether to include people. - /// Optional filter whether to include media. - /// Optional filter whether to include genres. - /// Optional filter whether to include studios. - /// Optional filter whether to include artists. - /// Search hint returned. - /// An with the results of the search. - [HttpGet] - [Description("Gets search hints based on a search term")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult 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) + IsKids = isKids, + IsMovie = isMovie, + IsNews = isNews, + IsSeries = isSeries, + IsSports = isSports + }); + + return new SearchHintResult(result.Items.Select(GetSearchHintResult).ToArray(), result.TotalRecordCount); + } + + /// + /// Gets the search hint result. + /// + /// The hint info. + /// SearchHintResult. + private SearchHint GetSearchHintResult(SearchHintInfo hintInfo) + { + var item = hintInfo.Item; + + var result = new SearchHint { - 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 - }); - - return new SearchHintResult(result.Items.Select(GetSearchHintResult).ToArray(), result.TotalRecordCount); - } - - /// - /// Gets the search hint result. - /// - /// The hint info. - /// SearchHintResult. - private SearchHint GetSearchHintResult(SearchHintInfo hintInfo) - { - 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 (primaryImageTag is not null) - { - result.PrimaryImageTag = primaryImageTag; - result.PrimaryImageAspectRatio = _dtoService.GetPrimaryImageAspectRatio(item); - } - - SetThumbImageInfo(result, item); - SetBackdropImageInfo(result, 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(); - } - - 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; - - MusicAlbum musicAlbum = song.AlbumEntity; - - if (musicAlbum is not null) - { - result.Album = musicAlbum.Name; - result.AlbumId = musicAlbum.Id; - } - else - { - result.Album = song.Album; - } - - break; - } - - if (!item.ChannelId.Equals(default)) - { - var channel = _libraryManager.GetItemById(item.ChannelId); - result.ChannelName = channel?.Name; - } - - return result; + if (item.IsFolder) + { + result.IsFolder = true; } - private void SetThumbImageInfo(SearchHint hint, BaseItem item) + var primaryImageTag = _imageProcessor.GetImageCacheTag(item, ImageType.Primary); + + if (primaryImageTag is not null) { - var itemWithImage = item.HasImage(ImageType.Thumb) ? item : null; + result.PrimaryImageTag = primaryImageTag; + result.PrimaryImageAspectRatio = _dtoService.GetPrimaryImageAspectRatio(item); + } - if (itemWithImage is null && item is Episode) - { - itemWithImage = GetParentWithImage(item, ImageType.Thumb); - } + SetThumbImageInfo(result, item); + SetBackdropImageInfo(result, item); - itemWithImage ??= GetParentWithImage(item, ImageType.Thumb); - - if (itemWithImage is not null) - { - var tag = _imageProcessor.GetImageCacheTag(itemWithImage, ImageType.Thumb); - - if (tag is not null) + 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) { - hint.ThumbImageTag = tag; - hint.ThumbImageItemId = itemWithImage.Id.ToString("N", CultureInfo.InvariantCulture); + result.Status = series.Status.Value.ToString(); } - } - } - private void SetBackdropImageInfo(SearchHint hint, BaseItem item) - { - var itemWithImage = (item.HasImage(ImageType.Backdrop) ? item : null) - ?? GetParentWithImage(item, ImageType.Backdrop); + 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 (itemWithImage is not null) - { - var tag = _imageProcessor.GetImageCacheTag(itemWithImage, ImageType.Backdrop); + MusicAlbum musicAlbum = song.AlbumEntity; - if (tag is not null) + if (musicAlbum is not null) { - hint.BackdropImageTag = tag; - hint.BackdropImageItemId = itemWithImage.Id.ToString("N", CultureInfo.InvariantCulture); + result.Album = musicAlbum.Name; + result.AlbumId = musicAlbum.Id; } - } + else + { + result.Album = song.Album; + } + + break; } - private T? GetParentWithImage(BaseItem item, ImageType type) - where T : BaseItem + if (!item.ChannelId.Equals(default)) { - return item.GetParents().OfType().FirstOrDefault(i => i.HasImage(type)); + var channel = _libraryManager.GetItemById(item.ChannelId); + result.ChannelName = channel?.Name; + } + + return result; + } + + private void SetThumbImageInfo(SearchHint hint, BaseItem item) + { + var itemWithImage = item.HasImage(ImageType.Thumb) ? item : null; + + if (itemWithImage is null && item is Episode) + { + itemWithImage = GetParentWithImage(item, ImageType.Thumb); + } + + itemWithImage ??= GetParentWithImage(item, ImageType.Thumb); + + if (itemWithImage 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); + } } } + + private void SetBackdropImageInfo(SearchHint hint, BaseItem item) + { + var itemWithImage = (item.HasImage(ImageType.Backdrop) ? item : null) + ?? GetParentWithImage(item, ImageType.Backdrop); + + if (itemWithImage 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); + } + } + } + + private T? GetParentWithImage(BaseItem item, ImageType type) + where T : BaseItem + { + return item.GetParents().OfType().FirstOrDefault(i => i.HasImage(type)); + } } diff --git a/Jellyfin.Api/Controllers/SessionController.cs b/Jellyfin.Api/Controllers/SessionController.cs index 25f9301351..e93456de66 100644 --- a/Jellyfin.Api/Controllers/SessionController.cs +++ b/Jellyfin.Api/Controllers/SessionController.cs @@ -19,480 +19,483 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// The session controller. +/// +[Route("")] +public class SessionController : BaseJellyfinApiController { + private readonly ISessionManager _sessionManager; + private readonly IUserManager _userManager; + private readonly IDeviceManager _deviceManager; + /// - /// The session controller. + /// Initializes a new instance of the class. /// - [Route("")] - public class SessionController : BaseJellyfinApiController + /// Instance of interface. + /// Instance of interface. + /// Instance of interface. + public SessionController( + ISessionManager sessionManager, + IUserManager userManager, + IDeviceManager deviceManager) { - private readonly ISessionManager _sessionManager; - private readonly IUserManager _userManager; - private readonly IDeviceManager _deviceManager; + _sessionManager = sessionManager; + _userManager = userManager; + _deviceManager = deviceManager; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of interface. - /// Instance of interface. - /// Instance of interface. - public SessionController( - ISessionManager sessionManager, - IUserManager userManager, - IDeviceManager deviceManager) + /// + /// Gets a list of sessions. + /// + /// Filter by sessions that a given user is allowed to remote control. + /// Filter by device Id. + /// Optional. Filter by sessions that were active in the last n seconds. + /// List of sessions returned. + /// An with the available sessions. + [HttpGet("Sessions")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> GetSessions( + [FromQuery] Guid? controllableByUserId, + [FromQuery] string? deviceId, + [FromQuery] int? activeWithinSeconds) + { + 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)); } - /// - /// Gets a list of sessions. - /// - /// Filter by sessions that a given user is allowed to remote control. - /// Filter by device Id. - /// Optional. Filter by sessions that were active in the last n seconds. - /// List of sessions returned. - /// An with the available sessions. - [HttpGet("Sessions")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> 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); + result = result.Where(i => i.UserId.Equals(default) || i.ContainsUser(controllableByUserId.Value)); + } - var user = _userManager.GetUserById(controllableByUserId.Value); + if (!user.HasPermission(PermissionKind.EnableSharedDeviceControl)) + { + result = result.Where(i => !i.UserId.Equals(default)); + } - if (!user.HasPermission(PermissionKind.EnableRemoteControlOfOtherUsers)) + 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 => + { + if (!string.IsNullOrWhiteSpace(i.DeviceId)) { - 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 (activeWithinSeconds.HasValue && activeWithinSeconds.Value > 0) - { - var minActiveDate = DateTime.UtcNow.AddSeconds(0 - activeWithinSeconds.Value); - result = result.Where(i => i.LastActivityDate >= minActiveDate); - } - - result = result.Where(i => - { - 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); - } - - /// - /// Instructs a session to browse to an item or view. - /// - /// The session Id. - /// The type of item to browse to. - /// The Id of the item. - /// The name of the item. - /// Instruction sent to session. - /// A . - [HttpPost("Sessions/{sessionId}/Viewing")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task DisplayContent( - [FromRoute, Required] string sessionId, - [FromQuery, Required] BaseItemKind itemType, - [FromQuery, Required] string itemId, - [FromQuery, Required] string itemName) - { - 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(); - } - - /// - /// Instructs a session to play an item. - /// - /// The session id. - /// The type of play command to issue (PlayNow, PlayNext, PlayLast). Clients who have not yet implemented play next and play last may play now. - /// The ids of the items to play, comma delimited. - /// The starting position of the first item. - /// Optional. The media source id. - /// Optional. The index of the audio stream to play. - /// Optional. The index of the subtitle stream to play. - /// Optional. The start index. - /// Instruction sent to session. - /// A . - [HttpPost("Sessions/{sessionId}/Playing")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task 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 - { - 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(); - } - - /// - /// Issues a playstate command to a client. - /// - /// The session id. - /// The . - /// The optional position ticks. - /// The optional controlling user id. - /// Playstate command sent to session. - /// A . - [HttpPost("Sessions/{sessionId}/Playing/{command}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task 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() - { - Command = command, - ControllingUserId = controllingUserId, - SeekPositionTicks = seekPositionTicks, - }, - CancellationToken.None) - .ConfigureAwait(false); - - return NoContent(); - } - - /// - /// Issues a system command to a client. - /// - /// The session id. - /// The command to send. - /// System command sent to session. - /// A . - [HttpPost("Sessions/{sessionId}/System/{command}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task 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); - - return NoContent(); - } - - /// - /// Issues a general command to a client. - /// - /// The session id. - /// The command to send. - /// General command sent to session. - /// A . - [HttpPost("Sessions/{sessionId}/Command/{command}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task SendGeneralCommand( - [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); - - return NoContent(); - } - - /// - /// Issues a full general command to a client. - /// - /// The session id. - /// The . - /// Full general command sent to session. - /// A . - [HttpPost("Sessions/{sessionId}/Command")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task SendFullGeneralCommand( - [FromRoute, Required] string sessionId, - [FromBody, Required] GeneralCommand command) - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - - ArgumentNullException.ThrowIfNull(command); - - command.ControllingUserId = currentSession.UserId; - - await _sessionManager.SendGeneralCommand( - currentSession.Id, - sessionId, - command, - CancellationToken.None) - .ConfigureAwait(false); - - return NoContent(); - } - - /// - /// Issues a command to a client to display a message to the user. - /// - /// The session id. - /// The object containing Header, Message Text, and TimeoutMs. - /// Message sent. - /// A . - [HttpPost("Sessions/{sessionId}/Message")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task 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); - - return NoContent(); - } - - /// - /// Adds an additional user to a session. - /// - /// The session id. - /// The user id. - /// User added to session. - /// A . - [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(); - } - - /// - /// Removes an additional user from a session. - /// - /// The session id. - /// The user id. - /// User removed from session. - /// A . - [HttpDelete("Sessions/{sessionId}/User/{userId}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult RemoveUserFromSession( - [FromRoute, Required] string sessionId, - [FromRoute, Required] Guid userId) - { - _sessionManager.RemoveAdditionalUser(sessionId, userId); - return NoContent(); - } - - /// - /// Updates capabilities for a device. - /// - /// The session id. - /// A list of playable media types, comma delimited. Audio, Video, Book, Photo. - /// A list of supported remote control commands, comma delimited. - /// Determines whether media can be played remotely.. - /// Determines whether sync is supported. - /// Determines whether the device supports a unique identifier. - /// Capabilities posted. - /// A . - [HttpPost("Sessions/Capabilities")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task 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)) - { - 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 true; }); - return NoContent(); } - /// - /// Updates capabilities for a device. - /// - /// The session id. - /// The . - /// Capabilities updated. - /// A . - [HttpPost("Sessions/Capabilities/Full")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task PostFullCapabilities( - [FromQuery] string? id, - [FromBody, Required] ClientCapabilitiesDto capabilities) + return Ok(result); + } + + /// + /// Instructs a session to browse to an item or view. + /// + /// The session Id. + /// The type of item to browse to. + /// The Id of the item. + /// The name of the item. + /// Instruction sent to session. + /// A . + [HttpPost("Sessions/{sessionId}/Viewing")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task DisplayContent( + [FromRoute, Required] string sessionId, + [FromQuery, Required] BaseItemKind itemType, + [FromQuery, Required] string itemId, + [FromQuery, Required] string itemName) + { + var command = new BrowseRequest { - if (string.IsNullOrWhiteSpace(id)) + 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(); + } + + /// + /// Instructs a session to play an item. + /// + /// The session id. + /// The type of play command to issue (PlayNow, PlayNext, PlayLast). Clients who have not yet implemented play next and play last may play now. + /// The ids of the items to play, comma delimited. + /// The starting position of the first item. + /// Optional. The media source id. + /// Optional. The index of the audio stream to play. + /// Optional. The index of the subtitle stream to play. + /// Optional. The start index. + /// Instruction sent to session. + /// A . + [HttpPost("Sessions/{sessionId}/Playing")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task 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 + { + 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(); + } + + /// + /// Issues a playstate command to a client. + /// + /// The session id. + /// The . + /// The optional position ticks. + /// The optional controlling user id. + /// Playstate command sent to session. + /// A . + [HttpPost("Sessions/{sessionId}/Playing/{command}")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task 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() { - id = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - } + Command = command, + ControllingUserId = controllingUserId, + SeekPositionTicks = seekPositionTicks, + }, + CancellationToken.None) + .ConfigureAwait(false); - _sessionManager.ReportCapabilities(id, capabilities.ToClientCapabilities()); + return NoContent(); + } - return NoContent(); - } - - /// - /// Reports that a session is viewing an item. - /// - /// The session id. - /// The item id. - /// Session reported to server. - /// A . - [HttpPost("Sessions/Viewing")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task ReportViewing( - [FromQuery] string? sessionId, - [FromQuery, Required] string? itemId) + /// + /// Issues a system command to a client. + /// + /// The session id. + /// The command to send. + /// System command sent to session. + /// A . + [HttpPost("Sessions/{sessionId}/System/{command}")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task SendSystemCommand( + [FromRoute, Required] string sessionId, + [FromRoute, Required] GeneralCommandType command) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var generalCommand = new GeneralCommand { - string session = sessionId ?? await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + Name = command, + ControllingUserId = currentSession.UserId + }; - _sessionManager.ReportNowViewingItem(session, itemId); - return NoContent(); - } + await _sessionManager.SendGeneralCommand(currentSession.Id, sessionId, generalCommand, CancellationToken.None).ConfigureAwait(false); - /// - /// Reports that a session has ended. - /// - /// Session end reported to server. - /// A . - [HttpPost("Sessions/Logout")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task ReportSessionEnded() + return NoContent(); + } + + /// + /// Issues a general command to a client. + /// + /// The session id. + /// The command to send. + /// General command sent to session. + /// A . + [HttpPost("Sessions/{sessionId}/Command/{command}")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task SendGeneralCommand( + [FromRoute, Required] string sessionId, + [FromRoute, Required] GeneralCommandType command) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + + var generalCommand = new GeneralCommand { - await _sessionManager.Logout(User.GetToken()).ConfigureAwait(false); - return NoContent(); + Name = command, + ControllingUserId = currentSession.UserId + }; + + await _sessionManager.SendGeneralCommand(currentSession.Id, sessionId, generalCommand, CancellationToken.None) + .ConfigureAwait(false); + + return NoContent(); + } + + /// + /// Issues a full general command to a client. + /// + /// The session id. + /// The . + /// Full general command sent to session. + /// A . + [HttpPost("Sessions/{sessionId}/Command")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task SendFullGeneralCommand( + [FromRoute, Required] string sessionId, + [FromBody, Required] GeneralCommand command) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + + ArgumentNullException.ThrowIfNull(command); + + command.ControllingUserId = currentSession.UserId; + + await _sessionManager.SendGeneralCommand( + currentSession.Id, + sessionId, + command, + CancellationToken.None) + .ConfigureAwait(false); + + return NoContent(); + } + + /// + /// Issues a command to a client to display a message to the user. + /// + /// The session id. + /// The object containing Header, Message Text, and TimeoutMs. + /// Message sent. + /// A . + [HttpPost("Sessions/{sessionId}/Message")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task SendMessageCommand( + [FromRoute, Required] string sessionId, + [FromBody, Required] MessageCommand command) + { + if (string.IsNullOrWhiteSpace(command.Header)) + { + command.Header = "Message from Server"; } - /// - /// Get all auth providers. - /// - /// Auth providers retrieved. - /// An with the auth providers. - [HttpGet("Auth/Providers")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> GetAuthProviders() + await _sessionManager.SendMessageCommand( + await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false), + sessionId, + command, + CancellationToken.None) + .ConfigureAwait(false); + + return NoContent(); + } + + /// + /// Adds an additional user to a session. + /// + /// The session id. + /// The user id. + /// User added to session. + /// A . + [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(); + } + + /// + /// Removes an additional user from a session. + /// + /// The session id. + /// The user id. + /// User removed from session. + /// A . + [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(); + } + + /// + /// Updates capabilities for a device. + /// + /// The session id. + /// A list of playable media types, comma delimited. Audio, Video, Book, Photo. + /// A list of supported remote control commands, comma delimited. + /// Determines whether media can be played remotely.. + /// Determines whether sync is supported. + /// Determines whether the device supports a unique identifier. + /// Capabilities posted. + /// A . + [HttpPost("Sessions/Capabilities")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task 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)) { - return _userManager.GetAuthenticationProviders(); + id = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); } - /// - /// Get all password reset providers. - /// - /// Password reset providers retrieved. - /// An with the password reset providers. - [HttpGet("Auth/PasswordResetProviders")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.RequiresElevation)] - public ActionResult> GetPasswordResetProviders() + _sessionManager.ReportCapabilities(id, new ClientCapabilities { - return _userManager.GetPasswordResetProviders(); + PlayableMediaTypes = playableMediaTypes, + SupportedCommands = supportedCommands, + SupportsMediaControl = supportsMediaControl, + SupportsSync = supportsSync, + SupportsPersistentIdentifier = supportsPersistentIdentifier + }); + return NoContent(); + } + + /// + /// Updates capabilities for a device. + /// + /// The session id. + /// The . + /// Capabilities updated. + /// A . + [HttpPost("Sessions/Capabilities/Full")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task PostFullCapabilities( + [FromQuery] string? id, + [FromBody, Required] ClientCapabilitiesDto capabilities) + { + if (string.IsNullOrWhiteSpace(id)) + { + id = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); } + + _sessionManager.ReportCapabilities(id, capabilities.ToClientCapabilities()); + + return NoContent(); + } + + /// + /// Reports that a session is viewing an item. + /// + /// The session id. + /// The item id. + /// Session reported to server. + /// A . + [HttpPost("Sessions/Viewing")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task 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(); + } + + /// + /// Reports that a session has ended. + /// + /// Session end reported to server. + /// A . + [HttpPost("Sessions/Logout")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task ReportSessionEnded() + { + await _sessionManager.Logout(User.GetToken()).ConfigureAwait(false); + return NoContent(); + } + + /// + /// Get all auth providers. + /// + /// Auth providers retrieved. + /// An with the auth providers. + [HttpGet("Auth/Providers")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> GetAuthProviders() + { + return _userManager.GetAuthenticationProviders(); + } + + /// + /// Get all password reset providers. + /// + /// Password reset providers retrieved. + /// An with the password reset providers. + [HttpGet("Auth/PasswordResetProviders")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.RequiresElevation)] + public ActionResult> GetPasswordResetProviders() + { + return _userManager.GetPasswordResetProviders(); } } diff --git a/Jellyfin.Api/Controllers/StartupController.cs b/Jellyfin.Api/Controllers/StartupController.cs index eec5779e64..1098733b2c 100644 --- a/Jellyfin.Api/Controllers/StartupController.cs +++ b/Jellyfin.Api/Controllers/StartupController.cs @@ -10,141 +10,144 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// The startup wizard controller. +/// +[Authorize(Policy = Policies.FirstTimeSetupOrElevated)] +public class StartupController : BaseJellyfinApiController { + private readonly IServerConfigurationManager _config; + private readonly IUserManager _userManager; + /// - /// The startup wizard controller. + /// Initializes a new instance of the class. /// - [Authorize(Policy = Policies.FirstTimeSetupOrElevated)] - public class StartupController : BaseJellyfinApiController + /// The server configuration manager. + /// The user manager. + public StartupController(IServerConfigurationManager config, IUserManager userManager) { - private readonly IServerConfigurationManager _config; - private readonly IUserManager _userManager; + _config = config; + _userManager = userManager; + } - /// - /// Initializes a new instance of the class. - /// - /// The server configuration manager. - /// The user manager. - public StartupController(IServerConfigurationManager config, IUserManager userManager) + /// + /// Completes the startup wizard. + /// + /// Startup wizard completed. + /// A indicating success. + [HttpPost("Complete")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult CompleteWizard() + { + _config.Configuration.IsStartupWizardCompleted = true; + _config.SaveConfiguration(); + return NoContent(); + } + + /// + /// Gets the initial startup wizard configuration. + /// + /// Initial startup wizard configuration retrieved. + /// An containing the initial startup wizard configuration. + [HttpGet("Configuration")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult GetStartupConfiguration() + { + return new StartupConfigurationDto { - _config = config; - _userManager = userManager; + UICulture = _config.Configuration.UICulture, + MetadataCountryCode = _config.Configuration.MetadataCountryCode, + PreferredMetadataLanguage = _config.Configuration.PreferredMetadataLanguage + }; + } + + /// + /// Sets the initial startup wizard configuration. + /// + /// The updated startup configuration. + /// Configuration saved. + /// A indicating success. + [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(); + } + + /// + /// Sets remote access and UPnP. + /// + /// The startup remote access dto. + /// Configuration saved. + /// A indicating success. + [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(); + } + + /// + /// Gets the first user. + /// + /// Initial user retrieved. + /// The first user. + [HttpGet("User")] + [HttpGet("FirstUser", Name = "GetFirstUser_2")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task 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 + }; + } + + /// + /// Sets the user name and password. + /// + /// The DTO containing username and password. + /// Updated user name and password. + /// + /// A that represents the asynchronous update operation. + /// The task result contains a indicating success. + /// + [HttpPost("User")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task UpdateStartupUser([FromBody] StartupUserDto startupUserDto) + { + var user = _userManager.Users.First(); + if (string.IsNullOrWhiteSpace(startupUserDto.Password)) + { + return BadRequest("Password must not be empty"); } - /// - /// Completes the startup wizard. - /// - /// Startup wizard completed. - /// A indicating success. - [HttpPost("Complete")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult CompleteWizard() + if (startupUserDto.Name is not null) { - _config.Configuration.IsStartupWizardCompleted = true; - _config.SaveConfiguration(); - return NoContent(); + user.Username = startupUserDto.Name; } - /// - /// Gets the initial startup wizard configuration. - /// - /// Initial startup wizard configuration retrieved. - /// An containing the initial startup wizard configuration. - [HttpGet("Configuration")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult GetStartupConfiguration() + await _userManager.UpdateUserAsync(user).ConfigureAwait(false); + + if (!string.IsNullOrEmpty(startupUserDto.Password)) { - return new StartupConfigurationDto - { - UICulture = _config.Configuration.UICulture, - MetadataCountryCode = _config.Configuration.MetadataCountryCode, - PreferredMetadataLanguage = _config.Configuration.PreferredMetadataLanguage - }; + await _userManager.ChangePassword(user, startupUserDto.Password).ConfigureAwait(false); } - /// - /// Sets the initial startup wizard configuration. - /// - /// The updated startup configuration. - /// Configuration saved. - /// A indicating success. - [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(); - } - - /// - /// Sets remote access and UPnP. - /// - /// The startup remote access dto. - /// Configuration saved. - /// A indicating success. - [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(); - } - - /// - /// Gets the first user. - /// - /// Initial user retrieved. - /// The first user. - [HttpGet("User")] - [HttpGet("FirstUser", Name = "GetFirstUser_2")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task 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 - }; - } - - /// - /// Sets the user name and password. - /// - /// The DTO containing username and password. - /// Updated user name and password. - /// - /// A that represents the asynchronous update operation. - /// The task result contains a indicating success. - /// - [HttpPost("User")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task UpdateStartupUser([FromBody] StartupUserDto startupUserDto) - { - var user = _userManager.Users.First(); - - if (startupUserDto.Name is not null) - { - user.Username = startupUserDto.Name; - } - - await _userManager.UpdateUserAsync(user).ConfigureAwait(false); - - if (!string.IsNullOrEmpty(startupUserDto.Password)) - { - await _userManager.ChangePassword(user, startupUserDto.Password).ConfigureAwait(false); - } - - return NoContent(); - } + return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/StudiosController.cs b/Jellyfin.Api/Controllers/StudiosController.cs index 1288fb5124..f434f60f51 100644 --- a/Jellyfin.Api/Controllers/StudiosController.cs +++ b/Jellyfin.Api/Controllers/StudiosController.cs @@ -1,6 +1,5 @@ using System; using System.ComponentModel.DataAnnotations; -using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; @@ -16,141 +15,142 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// Studios controller. +/// +[Authorize] +public class StudiosController : BaseJellyfinApiController { + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IDtoService _dtoService; + /// - /// Studios controller. + /// Initializes a new instance of the class. /// - [Authorize(Policy = Policies.DefaultAuthorization)] - public class StudiosController : BaseJellyfinApiController + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + 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; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - public StudiosController( - ILibraryManager libraryManager, - IUserManager userManager, - IDtoService dtoService) + /// + /// Gets all studios from a given item, folder, or the entire library. + /// + /// Optional. The record index to start at. All items with a lower index will be dropped from the results. + /// Optional. The maximum number of records to return. + /// Optional. Search term. + /// Specify this to localize the search to a specific item or folder. Omit to use the root. + /// Optional. Specify additional fields of information to return in the output. + /// Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited. + /// Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited. + /// Optional filter by items that are marked as favorite, or not. + /// Optional, include user data. + /// Optional, the max number of images to return, per image type. + /// Optional. The image types to include in the output. + /// User id. + /// Optional filter by items whose name is sorted equally or greater than a given input string. + /// Optional filter by items whose name is sorted equally than a given input string. + /// Optional filter by items whose name is equally or lesser than a given input string. + /// Optional, include image information in output. + /// Total record count. + /// Studios returned. + /// An containing the studios. + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> 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); + + User? user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + + var parentItem = _libraryManager.GetParentItem(parentId, userId); + + var query = new InternalItemsQuery(user) { - _libraryManager = libraryManager; - _userManager = userManager; - _dtoService = dtoService; - } + ExcludeItemTypes = excludeItemTypes, + IncludeItemTypes = includeItemTypes, + StartIndex = startIndex, + Limit = limit, + IsFavorite = isFavorite, + NameLessThan = nameLessThan, + NameStartsWith = nameStartsWith, + NameStartsWithOrGreater = nameStartsWithOrGreater, + DtoOptions = dtoOptions, + SearchTerm = searchTerm, + EnableTotalRecordCount = enableTotalRecordCount + }; - /// - /// Gets all studios from a given item, folder, or the entire library. - /// - /// Optional. The record index to start at. All items with a lower index will be dropped from the results. - /// Optional. The maximum number of records to return. - /// Optional. Search term. - /// Specify this to localize the search to a specific item or folder. Omit to use the root. - /// Optional. Specify additional fields of information to return in the output. - /// Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited. - /// Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited. - /// Optional filter by items that are marked as favorite, or not. - /// Optional, include user data. - /// Optional, the max number of images to return, per image type. - /// Optional. The image types to include in the output. - /// User id. - /// Optional filter by items whose name is sorted equally or greater than a given input string. - /// Optional filter by items whose name is sorted equally than a given input string. - /// Optional filter by items whose name is equally or lesser than a given input string. - /// Optional, include image information in output. - /// Total record count. - /// Studios returned. - /// An containing the studios. - [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> 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) + if (parentId.HasValue) { - 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); - - var parentItem = _libraryManager.GetParentItem(parentId, userId); - - var query = new InternalItemsQuery(user) + 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) - { - if (parentItem is Folder) - { - query.AncestorIds = new[] { parentId.Value }; - } - else - { - query.ItemIds = new[] { parentId.Value }; - } + query.AncestorIds = new[] { parentId.Value }; } - - var result = _libraryManager.GetStudios(query); - var shouldIncludeItemTypes = includeItemTypes.Length != 0; - return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user); - } - - /// - /// Gets a studio by name. - /// - /// Studio name. - /// Optional. Filter by user id, and attach user data. - /// Studio returned. - /// An containing the studio. - [HttpGet("{name}")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult GetStudio([FromRoute, Required] string name, [FromQuery] Guid? userId) - { - var dtoOptions = new DtoOptions().AddClientFields(User); - - var item = _libraryManager.GetStudio(name); - if (userId.HasValue && !userId.Equals(default)) + else { - var user = _userManager.GetUserById(userId.Value); - - return _dtoService.GetBaseItemDto(item, dtoOptions, user); + query.ItemIds = new[] { parentId.Value }; } - - return _dtoService.GetBaseItemDto(item, dtoOptions); } + + var result = _libraryManager.GetStudios(query); + var shouldIncludeItemTypes = includeItemTypes.Length != 0; + return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user); + } + + /// + /// Gets a studio by name. + /// + /// Studio name. + /// Optional. Filter by user id, and attach user data. + /// Studio returned. + /// An containing the studio. + [HttpGet("{name}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult GetStudio([FromRoute, Required] string name, [FromQuery] Guid? userId) + { + userId = RequestHelpers.GetUserId(User, userId); + var dtoOptions = new DtoOptions().AddClientFields(User); + + var item = _libraryManager.GetStudio(name); + if (!userId.Equals(default)) + { + var user = _userManager.GetUserById(userId.Value); + + return _dtoService.GetBaseItemDto(item, dtoOptions, user); + } + + return _dtoService.GetBaseItemDto(item, dtoOptions); } } diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs index c3ce1868e2..b3e9d62972 100644 --- a/Jellyfin.Api/Controllers/SubtitleController.cs +++ b/Jellyfin.Api/Controllers/SubtitleController.cs @@ -30,522 +30,519 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// Subtitle controller. +/// +[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 _logger; + /// - /// Subtitle controller. + /// Initializes a new instance of the class. /// - [Route("")] - public class SubtitleController : BaseJellyfinApiController + /// Instance of interface. + /// Instance of interface. + /// Instance of interface. + /// Instance of interface. + /// Instance of interface. + /// Instance of interface. + /// Instance of interface. + /// Instance of interface. + public SubtitleController( + IServerConfigurationManager serverConfigurationManager, + ILibraryManager libraryManager, + ISubtitleManager subtitleManager, + ISubtitleEncoder subtitleEncoder, + IMediaSourceManager mediaSourceManager, + IProviderManager providerManager, + IFileSystem fileSystem, + ILogger logger) { - 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 _logger; + _serverConfigurationManager = serverConfigurationManager; + _libraryManager = libraryManager; + _subtitleManager = subtitleManager; + _subtitleEncoder = subtitleEncoder; + _mediaSourceManager = mediaSourceManager; + _providerManager = providerManager; + _fileSystem = fileSystem; + _logger = logger; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of interface. - /// Instance of interface. - /// Instance of interface. - /// Instance of interface. - /// Instance of interface. - /// Instance of interface. - /// Instance of interface. - /// Instance of interface. - public SubtitleController( - IServerConfigurationManager serverConfigurationManager, - ILibraryManager libraryManager, - ISubtitleManager subtitleManager, - ISubtitleEncoder subtitleEncoder, - IMediaSourceManager mediaSourceManager, - IProviderManager providerManager, - IFileSystem fileSystem, - ILogger logger) + /// + /// Deletes an external subtitle file. + /// + /// The item id. + /// The index of the subtitle file. + /// Subtitle deleted. + /// Item not found. + /// A . + [HttpDelete("Videos/{itemId}/Subtitles/{index}")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult DeleteSubtitle( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] int index) + { + 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(); } - /// - /// Deletes an external subtitle file. - /// - /// The item id. - /// The index of the subtitle file. - /// Subtitle deleted. - /// Item not found. - /// A . - [HttpDelete("Videos/{itemId}/Subtitles/{index}")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult DeleteSubtitle( - [FromRoute, Required] Guid itemId, - [FromRoute, Required] int index) + _subtitleManager.DeleteSubtitles(item, index); + return NoContent(); + } + + /// + /// Search remote subtitles. + /// + /// The item id. + /// The language of the subtitles. + /// Optional. Only show subtitles which are a perfect match. + /// Subtitles retrieved. + /// An array of . + [HttpGet("Items/{itemId}/RemoteSearch/Subtitles/{language}")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task>> SearchRemoteSubtitles( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] string language, + [FromQuery] bool? isPerfectMatch) + { + var video = (Video)_libraryManager.GetItemById(itemId); + + return await _subtitleManager.SearchSubtitles(video, language, isPerfectMatch, false, CancellationToken.None).ConfigureAwait(false); + } + + /// + /// Downloads a remote subtitle. + /// + /// The item id. + /// The subtitle id. + /// Subtitle downloaded. + /// A . + [HttpPost("Items/{itemId}/RemoteSearch/Subtitles/{subtitleId}")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task DownloadRemoteSubtitles( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] string subtitleId) + { + var video = (Video)_libraryManager.GetItemById(itemId); + + try { - var item = _libraryManager.GetItemById(itemId); + await _subtitleManager.DownloadSubtitles(video, subtitleId, CancellationToken.None) + .ConfigureAwait(false); - if (item is null) - { - return NotFound(); - } - - _subtitleManager.DeleteSubtitles(item, index); - return NoContent(); + _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error downloading subtitles"); } - /// - /// Search remote subtitles. - /// - /// The item id. - /// The language of the subtitles. - /// Optional. Only show subtitles which are a perfect match. - /// Subtitles retrieved. - /// An array of . - [HttpGet("Items/{itemId}/RemoteSearch/Subtitles/{language}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task>> SearchRemoteSubtitles( - [FromRoute, Required] Guid itemId, - [FromRoute, Required] string language, - [FromQuery] bool? isPerfectMatch) - { - var video = (Video)_libraryManager.GetItemById(itemId); + return NoContent(); + } - return await _subtitleManager.SearchSubtitles(video, language, isPerfectMatch, false, CancellationToken.None).ConfigureAwait(false); + /// + /// Gets the remote subtitles. + /// + /// The item id. + /// File returned. + /// A with the subtitle file. + [HttpGet("Providers/Subtitles/Subtitles/{id}")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [Produces(MediaTypeNames.Application.Octet)] + [ProducesFile("text/*")] + public async Task GetRemoteSubtitles([FromRoute, Required] string id) + { + var result = await _subtitleManager.GetRemoteSubtitles(id, CancellationToken.None).ConfigureAwait(false); + + return File(result.Stream, MimeTypes.GetMimeType("file." + result.Format)); + } + + /// + /// Gets subtitles in a specified format. + /// + /// The (route) item id. + /// The (route) media source id. + /// The (route) subtitle stream index. + /// The (route) format of the returned subtitle. + /// The item id. + /// The media source id. + /// The subtitle stream index. + /// The format of the returned subtitle. + /// Optional. The end position of the subtitle in ticks. + /// Optional. Whether to copy the timestamps. + /// Optional. Whether to add a VTT time map. + /// The start position of the subtitle in ticks. + /// File returned. + /// A with the subtitle file. + [HttpGet("Videos/{routeItemId}/{routeMediaSourceId}/Subtitles/{routeIndex}/Stream.{routeFormat}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesFile("text/*")] + public async Task 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; + + if (string.Equals(format, "js", StringComparison.OrdinalIgnoreCase)) + { + format = "json"; } - /// - /// Downloads a remote subtitle. - /// - /// The item id. - /// The subtitle id. - /// Subtitle downloaded. - /// A . - [HttpPost("Items/{itemId}/RemoteSearch/Subtitles/{subtitleId}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task DownloadRemoteSubtitles( - [FromRoute, Required] Guid itemId, - [FromRoute, Required] string subtitleId) + if (string.IsNullOrEmpty(format)) { - var video = (Video)_libraryManager.GetItemById(itemId); + var item = (Video)_libraryManager.GetItemById(itemId.Value); - try - { - await _subtitleManager.DownloadSubtitles(video, subtitleId, CancellationToken.None) - .ConfigureAwait(false); + var idString = itemId.Value.ToString("N", CultureInfo.InvariantCulture); + var mediaSource = _mediaSourceManager.GetStaticMediaSources(item, false) + .First(i => string.Equals(i.Id, mediaSourceId ?? idString, StringComparison.Ordinal)); - _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error downloading subtitles"); - } + var subtitleStream = mediaSource.MediaStreams + .First(i => i.Type == MediaStreamType.Subtitle && i.Index == index); - return NoContent(); + return PhysicalFile(subtitleStream.Path, MimeTypes.GetMimeType(subtitleStream.Path)); } - /// - /// Gets the remote subtitles. - /// - /// The item id. - /// File returned. - /// A with the subtitle file. - [HttpGet("Providers/Subtitles/Subtitles/{id}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [Produces(MediaTypeNames.Application.Octet)] - [ProducesFile("text/*")] - public async Task GetRemoteSubtitles([FromRoute, Required] string id) + if (string.Equals(format, "vtt", StringComparison.OrdinalIgnoreCase) && addVttTimeMap) { - var result = await _subtitleManager.GetRemoteSubtitles(id, CancellationToken.None).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); - return File(result.Stream, MimeTypes.GetMimeType("file." + result.Format)); + var text = await reader.ReadToEndAsync().ConfigureAwait(false); + + 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)); + } } - /// - /// Gets subtitles in a specified format. - /// - /// The (route) item id. - /// The (route) media source id. - /// The (route) subtitle stream index. - /// The (route) format of the returned subtitle. - /// The item id. - /// The media source id. - /// The subtitle stream index. - /// The format of the returned subtitle. - /// Optional. The end position of the subtitle in ticks. - /// Optional. Whether to copy the timestamps. - /// Optional. Whether to add a VTT time map. - /// The start position of the subtitle in ticks. - /// File returned. - /// A with the subtitle file. - [HttpGet("Videos/{routeItemId}/{routeMediaSourceId}/Subtitles/{routeIndex}/Stream.{routeFormat}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesFile("text/*")] - public async Task 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; - - if (string.Equals(format, "js", StringComparison.OrdinalIgnoreCase)) - { - format = "json"; - } - - if (string.IsNullOrEmpty(format)) - { - 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 subtitleStream = mediaSource.MediaStreams - .First(i => i.Type == MediaStreamType.Subtitle && i.Index == index); - - return PhysicalFile(subtitleStream.Path, MimeTypes.GetMimeType(subtitleStream.Path)); - } - - 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)) - { - using var reader = new StreamReader(stream); - - var text = await reader.ReadToEndAsync().ConfigureAwait(false); - - 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( - await EncodeSubtitles( - itemId.Value, - mediaSourceId, - index.Value, - format, - startPositionTicks, - endPositionTicks, - copyTimestamps).ConfigureAwait(false), - MimeTypes.GetMimeType("file." + format)); - } - - /// - /// Gets subtitles in a specified format. - /// - /// The (route) item id. - /// The (route) media source id. - /// The (route) subtitle stream index. - /// The (route) start position of the subtitle in ticks. - /// The (route) format of the returned subtitle. - /// The item id. - /// The media source id. - /// The subtitle stream index. - /// The start position of the subtitle in ticks. - /// The format of the returned subtitle. - /// Optional. The end position of the subtitle in ticks. - /// Optional. Whether to copy the timestamps. - /// Optional. Whether to add a VTT time map. - /// File returned. - /// A with the subtitle file. - [HttpGet("Videos/{routeItemId}/{routeMediaSourceId}/Subtitles/{routeIndex}/{routeStartPositionTicks}/Stream.{routeFormat}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesFile("text/*")] - public Task 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, - format, - endPositionTicks, - copyTimestamps, - addVttTimeMap, - startPositionTicks ?? routeStartPositionTicks); - } - - /// - /// Gets an HLS subtitle playlist. - /// - /// The item id. - /// The subtitle stream index. - /// The media source id. - /// The subtitle segment length. - /// Subtitle playlist retrieved. - /// A with the HLS subtitle playlist. - [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 GetSubtitlePlaylist( - [FromRoute, Required] Guid itemId, - [FromRoute, Required] int index, - [FromRoute, Required] string mediaSourceId, - [FromQuery, Required] int segmentLength) - { - var item = (Video)_libraryManager.GetItemById(itemId); - - var mediaSource = await _mediaSourceManager.GetMediaSource(item, mediaSourceId, null, false, CancellationToken.None).ConfigureAwait(false); - - var runtime = mediaSource.RunTimeTicks ?? -1; - - if (runtime <= 0) - { - throw new ArgumentException("HLS Subtitles are not supported for this media."); - } - - 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)"); - } - - 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"); - - long positionTicks = 0; - - var accessToken = User.GetToken(); - - while (positionTicks < runtime) - { - var remaining = runtime - positionTicks; - var lengthTicks = Math.Min(remaining, segmentLengthTicks); - - builder.Append("#EXTINF:") - .Append(TimeSpan.FromTicks(lengthTicks).TotalSeconds) - .Append(',') - .AppendLine(); - - var endPositionTicks = Math.Min(runtime, positionTicks + segmentLengthTicks); - - 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); - - builder.AppendLine(url); - - positionTicks += segmentLengthTicks; - } - - builder.AppendLine("#EXT-X-ENDLIST"); - return File(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8")); - } - - /// - /// Upload an external subtitle file. - /// - /// The item the subtitle belongs to. - /// The request body. - /// Subtitle uploaded. - /// A . - [HttpPost("Videos/{itemId}/Subtitles")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task 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(); - } - } - - /// - /// Encodes a subtitle in the specified format. - /// - /// The media id. - /// The source media id. - /// The subtitle index. - /// The format to convert to. - /// The start position in ticks. - /// The end position in ticks. - /// Whether to copy the timestamps. - /// A with the new subtitle file. - private Task 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, + index.Value, format, startPositionTicks, - endPositionTicks ?? 0, - copyTimestamps, - CancellationToken.None); + endPositionTicks, + copyTimestamps).ConfigureAwait(false), + MimeTypes.GetMimeType("file." + format)); + } + + /// + /// Gets subtitles in a specified format. + /// + /// The (route) item id. + /// The (route) media source id. + /// The (route) subtitle stream index. + /// The (route) start position of the subtitle in ticks. + /// The (route) format of the returned subtitle. + /// The item id. + /// The media source id. + /// The subtitle stream index. + /// The start position of the subtitle in ticks. + /// The format of the returned subtitle. + /// Optional. The end position of the subtitle in ticks. + /// Optional. Whether to copy the timestamps. + /// Optional. Whether to add a VTT time map. + /// File returned. + /// A with the subtitle file. + [HttpGet("Videos/{routeItemId}/{routeMediaSourceId}/Subtitles/{routeIndex}/{routeStartPositionTicks}/Stream.{routeFormat}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesFile("text/*")] + public Task 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); + } + + /// + /// Gets an HLS subtitle playlist. + /// + /// The item id. + /// The subtitle stream index. + /// The media source id. + /// The subtitle segment length. + /// Subtitle playlist retrieved. + /// A with the HLS subtitle playlist. + [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 GetSubtitlePlaylist( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] int index, + [FromRoute, Required] string mediaSourceId, + [FromQuery, Required] int segmentLength) + { + var item = (Video)_libraryManager.GetItemById(itemId); + + var mediaSource = await _mediaSourceManager.GetMediaSource(item, mediaSourceId, null, false, CancellationToken.None).ConfigureAwait(false); + + var runtime = mediaSource.RunTimeTicks ?? -1; + + if (runtime <= 0) + { + throw new ArgumentException("HLS Subtitles are not supported for this media."); } - /// - /// Gets a list of available fallback font files. - /// - /// Information retrieved. - /// An array of with the available font files. - [HttpGet("FallbackFont/Fonts")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public IEnumerable GetFallbackFontList() + var segmentLengthTicks = TimeSpan.FromSeconds(segmentLength).Ticks; + if (segmentLengthTicks <= 0) { - var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); - var fallbackFontPath = encodingOptions.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) - { - sizeCounter += fontFile.Size; - if (sizeCounter >= MaxSize) - { - _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; - } + throw new ArgumentException("segmentLength was not given, or it was given incorrectly. (It should be bigger than 0)"); } - /// - /// Gets a fallback font file. - /// - /// The name of the fallback font file to get. - /// Fallback font file retrieved. - /// The fallback font file. - [HttpGet("FallbackFont/Fonts/{name}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesFile("font/*")] - public ActionResult GetFallbackFont([FromRoute, Required] string name) + 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"); + + long positionTicks = 0; + + var accessToken = User.GetToken(); + + while (positionTicks < runtime) { - var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); - var fallbackFontPath = encodingOptions.FallbackFontPath; + var remaining = runtime - positionTicks; + var lengthTicks = Math.Min(remaining, segmentLengthTicks); - if (!string.IsNullOrEmpty(fallbackFontPath)) - { - var fontFile = _fileSystem.GetFiles(fallbackFontPath) - .First(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase)); - var fileSize = fontFile?.Length; + builder.Append("#EXTINF:") + .Append(TimeSpan.FromTicks(lengthTicks).TotalSeconds) + .Append(',') + .AppendLine(); - if (fontFile is not null && fileSize is not null && fileSize > 0) + var endPositionTicks = Math.Min(runtime, positionTicks + segmentLengthTicks); + + 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); + + builder.AppendLine(url); + + positionTicks += segmentLengthTicks; + } + + builder.AppendLine("#EXT-X-ENDLIST"); + return File(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8")); + } + + /// + /// Upload an external subtitle file. + /// + /// The item the subtitle belongs to. + /// The request body. + /// Subtitle uploaded. + /// A . + [HttpPost("Videos/{itemId}/Subtitles")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task 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 { - _logger.LogDebug("Fallback font size is {FileSize} Bytes", fileSize); - return PhysicalFile(fontFile.FullName, MimeTypes.GetMimeType(fontFile.FullName)); - } - else - { - _logger.LogWarning("The selected font is null or empty"); - } - } - else - { - _logger.LogWarning("The path of fallback font folder has not been set"); - encodingOptions.EnableFallbackFont = false; - } + Format = body.Format, + Language = body.Language, + IsForced = body.IsForced, + Stream = memoryStream + }).ConfigureAwait(false); + _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High); - // returning HTTP 204 will break the SubtitlesOctopus - return Ok(); + return NoContent(); } } + + /// + /// Encodes a subtitle in the specified format. + /// + /// The media id. + /// The source media id. + /// The subtitle index. + /// The format to convert to. + /// The start position in ticks. + /// The end position in ticks. + /// Whether to copy the timestamps. + /// A with the new subtitle file. + private Task 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); + } + + /// + /// Gets a list of available fallback font files. + /// + /// Information retrieved. + /// An array of with the available font files. + [HttpGet("FallbackFont/Fonts")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + public IEnumerable GetFallbackFontList() + { + var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); + var fallbackFontPath = encodingOptions.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) + { + sizeCounter += fontFile.Size; + if (sizeCounter >= MaxSize) + { + _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; + } + } + + /// + /// Gets a fallback font file. + /// + /// The name of the fallback font file to get. + /// Fallback font file retrieved. + /// The fallback font file. + [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 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)); + } + + _logger.LogWarning("The selected font is null or empty"); + } + else + { + _logger.LogWarning("The path of fallback font folder has not been set"); + encodingOptions.EnableFallbackFont = false; + } + + // returning HTTP 204 will break the SubtitlesOctopus + return Ok(); + } } diff --git a/Jellyfin.Api/Controllers/SuggestionsController.cs b/Jellyfin.Api/Controllers/SuggestionsController.cs index 1cf528153f..5b808f257c 100644 --- a/Jellyfin.Api/Controllers/SuggestionsController.cs +++ b/Jellyfin.Api/Controllers/SuggestionsController.cs @@ -1,6 +1,5 @@ using System; using System.ComponentModel.DataAnnotations; -using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; @@ -13,80 +12,79 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// The suggestions controller. +/// +[Route("")] +[Authorize] +public class SuggestionsController : BaseJellyfinApiController { + private readonly IDtoService _dtoService; + private readonly IUserManager _userManager; + private readonly ILibraryManager _libraryManager; + /// - /// The suggestions controller. + /// Initializes a new instance of the class. /// - [Route("")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class SuggestionsController : BaseJellyfinApiController + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + 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; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - public SuggestionsController( - IDtoService dtoService, - IUserManager userManager, - ILibraryManager libraryManager) + /// + /// Gets suggestions. + /// + /// The user id. + /// The media types. + /// The type. + /// Optional. The start index. + /// Optional. The limit. + /// Whether to enable the total record count. + /// Suggestions returned. + /// A with the suggestions. + [HttpGet("Users/{userId}/Suggestions")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> 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); + + var dtoOptions = new DtoOptions().AddClientFields(User); + var result = _libraryManager.GetItemsResult(new InternalItemsQuery(user) { - _dtoService = dtoService; - _userManager = userManager; - _libraryManager = libraryManager; - } + OrderBy = new[] { (ItemSortBy.Random, SortOrder.Descending) }, + MediaTypes = mediaType, + IncludeItemTypes = type, + IsVirtualItem = false, + StartIndex = startIndex, + Limit = limit, + DtoOptions = dtoOptions, + EnableTotalRecordCount = enableTotalRecordCount, + Recursive = true + }); - /// - /// Gets suggestions. - /// - /// The user id. - /// The media types. - /// The type. - /// Optional. The start index. - /// Optional. The limit. - /// Whether to enable the total record count. - /// Suggestions returned. - /// A with the suggestions. - [HttpGet("Users/{userId}/Suggestions")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> 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); + var dtoList = _dtoService.GetBaseItemDtos(result.Items, dtoOptions, user); - 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 - }); - - var dtoList = _dtoService.GetBaseItemDtos(result.Items, dtoOptions, user); - - return new QueryResult( - startIndex, - result.TotalRecordCount, - dtoList); - } + return new QueryResult( + startIndex, + result.TotalRecordCount, + dtoList); } } diff --git a/Jellyfin.Api/Controllers/SyncPlayController.cs b/Jellyfin.Api/Controllers/SyncPlayController.cs index 99347246e0..23abba7dc7 100644 --- a/Jellyfin.Api/Controllers/SyncPlayController.cs +++ b/Jellyfin.Api/Controllers/SyncPlayController.cs @@ -16,409 +16,408 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// The sync play controller. +/// +[Authorize(Policy = Policies.SyncPlayHasAccess)] +public class SyncPlayController : BaseJellyfinApiController { + private readonly ISessionManager _sessionManager; + private readonly ISyncPlayManager _syncPlayManager; + private readonly IUserManager _userManager; + /// - /// The sync play controller. + /// Initializes a new instance of the class. /// - [Authorize(Policy = Policies.SyncPlayHasAccess)] - public class SyncPlayController : BaseJellyfinApiController + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + public SyncPlayController( + ISessionManager sessionManager, + ISyncPlayManager syncPlayManager, + IUserManager userManager) { - private readonly ISessionManager _sessionManager; - private readonly ISyncPlayManager _syncPlayManager; - private readonly IUserManager _userManager; + _sessionManager = sessionManager; + _syncPlayManager = syncPlayManager; + _userManager = userManager; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - public SyncPlayController( - ISessionManager sessionManager, - ISyncPlayManager syncPlayManager, - IUserManager userManager) - { - _sessionManager = sessionManager; - _syncPlayManager = syncPlayManager; - _userManager = userManager; - } + /// + /// Create a new SyncPlay group. + /// + /// The settings of the new group. + /// New group created. + /// A indicating success. + [HttpPost("New")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayCreateGroup)] + public async Task 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(); + } - /// - /// Create a new SyncPlay group. - /// - /// The settings of the new group. - /// New group created. - /// A indicating success. - [HttpPost("New")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayCreateGroup)] - public async Task 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(); - } + /// + /// Join an existing SyncPlay group. + /// + /// The group to join. + /// Group join successful. + /// A indicating success. + [HttpPost("Join")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayJoinGroup)] + public async Task 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(); + } - /// - /// Join an existing SyncPlay group. - /// - /// The group to join. - /// Group join successful. - /// A indicating success. - [HttpPost("Join")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayJoinGroup)] - public async Task 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(); - } + /// + /// Leave the joined SyncPlay group. + /// + /// Group leave successful. + /// A indicating success. + [HttpPost("Leave")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task SyncPlayLeaveGroup() + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new LeaveGroupRequest(); + _syncPlayManager.LeaveGroup(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } - /// - /// Leave the joined SyncPlay group. - /// - /// Group leave successful. - /// A indicating success. - [HttpPost("Leave")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task SyncPlayLeaveGroup() - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new LeaveGroupRequest(); - _syncPlayManager.LeaveGroup(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// + /// Gets all SyncPlay groups. + /// + /// Groups returned. + /// An containing the available SyncPlay groups. + [HttpGet("List")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.SyncPlayJoinGroup)] + public async Task>> SyncPlayGetGroups() + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new ListGroupsRequest(); + return Ok(_syncPlayManager.ListGroups(currentSession, syncPlayRequest).AsEnumerable()); + } - /// - /// Gets all SyncPlay groups. - /// - /// Groups returned. - /// An containing the available SyncPlay groups. - [HttpGet("List")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.SyncPlayJoinGroup)] - public async Task>> SyncPlayGetGroups() - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new ListGroupsRequest(); - return Ok(_syncPlayManager.ListGroups(currentSession, syncPlayRequest).AsEnumerable()); - } + /// + /// Request to set new playlist in SyncPlay group. + /// + /// The new playlist to play in the group. + /// Queue update sent to all group members. + /// A indicating success. + [HttpPost("SetNewQueue")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task 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(); + } - /// - /// Request to set new playlist in SyncPlay group. - /// - /// The new playlist to play in the group. - /// Queue update sent to all group members. - /// A indicating success. - [HttpPost("SetNewQueue")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task 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(); - } + /// + /// Request to change playlist item in SyncPlay group. + /// + /// The new item to play. + /// Queue update sent to all group members. + /// A indicating success. + [HttpPost("SetPlaylistItem")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task 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(); + } - /// - /// Request to change playlist item in SyncPlay group. - /// - /// The new item to play. - /// Queue update sent to all group members. - /// A indicating success. - [HttpPost("SetPlaylistItem")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task 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(); - } + /// + /// Request to remove items from the playlist in SyncPlay group. + /// + /// The items to remove. + /// Queue update sent to all group members. + /// A indicating success. + [HttpPost("RemoveFromPlaylist")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task 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(); + } - /// - /// Request to remove items from the playlist in SyncPlay group. - /// - /// The items to remove. - /// Queue update sent to all group members. - /// A indicating success. - [HttpPost("RemoveFromPlaylist")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task 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(); - } + /// + /// Request to move an item in the playlist in SyncPlay group. + /// + /// The new position for the item. + /// Queue update sent to all group members. + /// A indicating success. + [HttpPost("MovePlaylistItem")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task 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(); + } - /// - /// Request to move an item in the playlist in SyncPlay group. - /// - /// The new position for the item. - /// Queue update sent to all group members. - /// A indicating success. - [HttpPost("MovePlaylistItem")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task 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(); - } + /// + /// Request to queue items to the playlist of a SyncPlay group. + /// + /// The items to add. + /// Queue update sent to all group members. + /// A indicating success. + [HttpPost("Queue")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task 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(); + } - /// - /// Request to queue items to the playlist of a SyncPlay group. - /// - /// The items to add. - /// Queue update sent to all group members. - /// A indicating success. - [HttpPost("Queue")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task 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(); - } + /// + /// Request unpause in SyncPlay group. + /// + /// Unpause update sent to all group members. + /// A indicating success. + [HttpPost("Unpause")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task SyncPlayUnpause() + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new UnpauseGroupRequest(); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } - /// - /// Request unpause in SyncPlay group. - /// - /// Unpause update sent to all group members. - /// A indicating success. - [HttpPost("Unpause")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task SyncPlayUnpause() - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new UnpauseGroupRequest(); - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// + /// Request pause in SyncPlay group. + /// + /// Pause update sent to all group members. + /// A indicating success. + [HttpPost("Pause")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task SyncPlayPause() + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new PauseGroupRequest(); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } - /// - /// Request pause in SyncPlay group. - /// - /// Pause update sent to all group members. - /// A indicating success. - [HttpPost("Pause")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task SyncPlayPause() - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new PauseGroupRequest(); - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// + /// Request stop in SyncPlay group. + /// + /// Stop update sent to all group members. + /// A indicating success. + [HttpPost("Stop")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task SyncPlayStop() + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new StopGroupRequest(); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } - /// - /// Request stop in SyncPlay group. - /// - /// Stop update sent to all group members. - /// A indicating success. - [HttpPost("Stop")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task SyncPlayStop() - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new StopGroupRequest(); - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// + /// Request seek in SyncPlay group. + /// + /// The new playback position. + /// Seek update sent to all group members. + /// A indicating success. + [HttpPost("Seek")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task 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(); + } - /// - /// Request seek in SyncPlay group. - /// - /// The new playback position. - /// Seek update sent to all group members. - /// A indicating success. - [HttpPost("Seek")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task 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(); - } + /// + /// Notify SyncPlay group that member is buffering. + /// + /// The player status. + /// Group state update sent to all group members. + /// A indicating success. + [HttpPost("Buffering")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task 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(); + } - /// - /// Notify SyncPlay group that member is buffering. - /// - /// The player status. - /// Group state update sent to all group members. - /// A indicating success. - [HttpPost("Buffering")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task 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(); - } + /// + /// Notify SyncPlay group that member is ready for playback. + /// + /// The player status. + /// Group state update sent to all group members. + /// A indicating success. + [HttpPost("Ready")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task 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(); + } - /// - /// Notify SyncPlay group that member is ready for playback. - /// - /// The player status. - /// Group state update sent to all group members. - /// A indicating success. - [HttpPost("Ready")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task 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(); - } + /// + /// Request SyncPlay group to ignore member during group-wait. + /// + /// The settings to set. + /// Member state updated. + /// A indicating success. + [HttpPost("SetIgnoreWait")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task 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(); + } - /// - /// Request SyncPlay group to ignore member during group-wait. - /// - /// The settings to set. - /// Member state updated. - /// A indicating success. - [HttpPost("SetIgnoreWait")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task 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(); - } + /// + /// Request next item in SyncPlay group. + /// + /// The current item information. + /// Next item update sent to all group members. + /// A indicating success. + [HttpPost("NextItem")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task 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(); + } - /// - /// Request next item in SyncPlay group. - /// - /// The current item information. - /// Next item update sent to all group members. - /// A indicating success. - [HttpPost("NextItem")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task 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(); - } + /// + /// Request previous item in SyncPlay group. + /// + /// The current item information. + /// Previous item update sent to all group members. + /// A indicating success. + [HttpPost("PreviousItem")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task 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(); + } - /// - /// Request previous item in SyncPlay group. - /// - /// The current item information. - /// Previous item update sent to all group members. - /// A indicating success. - [HttpPost("PreviousItem")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task 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(); - } + /// + /// Request to set repeat mode in SyncPlay group. + /// + /// The new repeat mode. + /// Play queue update sent to all group members. + /// A indicating success. + [HttpPost("SetRepeatMode")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task 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(); + } - /// - /// Request to set repeat mode in SyncPlay group. - /// - /// The new repeat mode. - /// Play queue update sent to all group members. - /// A indicating success. - [HttpPost("SetRepeatMode")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task 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(); - } + /// + /// Request to set shuffle mode in SyncPlay group. + /// + /// The new shuffle mode. + /// Play queue update sent to all group members. + /// A indicating success. + [HttpPost("SetShuffleMode")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task 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(); + } - /// - /// Request to set shuffle mode in SyncPlay group. - /// - /// The new shuffle mode. - /// Play queue update sent to all group members. - /// A indicating success. - [HttpPost("SetShuffleMode")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task 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(); - } - - /// - /// Update session ping. - /// - /// The new ping. - /// Ping updated. - /// A indicating success. - [HttpPost("Ping")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task 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(); - } + /// + /// Update session ping. + /// + /// The new ping. + /// Ping updated. + /// A indicating success. + [HttpPost("Ping")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task SyncPlayPing( + [FromBody, Required] PingRequestDto requestData) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new PingGroupRequest(requestData.Ping); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/SystemController.cs b/Jellyfin.Api/Controllers/SystemController.cs index 2d594293e0..9ed69f4205 100644 --- a/Jellyfin.Api/Controllers/SystemController.cs +++ b/Jellyfin.Api/Controllers/SystemController.cs @@ -20,204 +20,215 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// The system controller. +/// +public class SystemController : BaseJellyfinApiController { + private readonly IServerApplicationHost _appHost; + private readonly IApplicationPaths _appPaths; + private readonly IFileSystem _fileSystem; + private readonly INetworkManager _network; + private readonly ILogger _logger; + /// - /// The system controller. + /// Initializes a new instance of the class. /// - public class SystemController : BaseJellyfinApiController + /// Instance of interface. + /// Instance of interface. + /// Instance of interface. + /// Instance of interface. + /// Instance of interface. + public SystemController( + IServerConfigurationManager serverConfigurationManager, + IServerApplicationHost appHost, + IFileSystem fileSystem, + INetworkManager network, + ILogger logger) { - private readonly IServerApplicationHost _appHost; - private readonly IApplicationPaths _appPaths; - private readonly IFileSystem _fileSystem; - private readonly INetworkManager _network; - private readonly ILogger _logger; + _appPaths = serverConfigurationManager.ApplicationPaths; + _appHost = appHost; + _fileSystem = fileSystem; + _network = network; + _logger = logger; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of interface. - /// Instance of interface. - /// Instance of interface. - /// Instance of interface. - /// Instance of interface. - public SystemController( - IServerConfigurationManager serverConfigurationManager, - IServerApplicationHost appHost, - IFileSystem fileSystem, - INetworkManager network, - ILogger logger) + /// + /// Gets information about the server. + /// + /// Information retrieved. + /// User does not have permission to retrieve information. + /// A with info about the system. + [HttpGet("Info")] + [Authorize(Policy = Policies.FirstTimeSetupOrIgnoreParentalControl)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public ActionResult GetSystemInfo() + { + return _appHost.GetSystemInfo(Request); + } + + /// + /// Gets public information about the server. + /// + /// Information retrieved. + /// A with public info about the system. + [HttpGet("Info/Public")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult GetPublicSystemInfo() + { + return _appHost.GetPublicSystemInfo(Request); + } + + /// + /// Pings the system. + /// + /// Information retrieved. + /// The server name. + [HttpGet("Ping", Name = "GetPingSystem")] + [HttpPost("Ping", Name = "PostPingSystem")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult PingSystem() + { + return _appHost.Name; + } + + /// + /// Restarts the application. + /// + /// Server restarted. + /// User does not have permission to restart server. + /// No content. Server restarted. + [HttpPost("Restart")] + [Authorize(Policy = Policies.LocalAccessOrRequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public ActionResult RestartApplication() + { + Task.Run(async () => { - _appPaths = serverConfigurationManager.ApplicationPaths; - _appHost = appHost; - _fileSystem = fileSystem; - _network = network; - _logger = logger; + await Task.Delay(100).ConfigureAwait(false); + _appHost.Restart(); + }); + return NoContent(); + } + + /// + /// Shuts down the application. + /// + /// Server shut down. + /// User does not have permission to shutdown server. + /// No content. Server shut down. + [HttpPost("Shutdown")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public ActionResult ShutdownApplication() + { + Task.Run(async () => + { + await Task.Delay(100).ConfigureAwait(false); + await _appHost.Shutdown().ConfigureAwait(false); + }); + return NoContent(); + } + + /// + /// Gets a list of available server log files. + /// + /// Information retrieved. + /// User does not have permission to get server logs. + /// An array of with the available log files. + [HttpGet("Logs")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public ActionResult GetServerLogs() + { + IEnumerable files; + + try + { + files = _fileSystem.GetFiles(_appPaths.LogDirectoryPath, new[] { ".txt", ".log" }, true, false); + } + catch (IOException ex) + { + _logger.LogError(ex, "Error getting logs"); + files = Enumerable.Empty(); } - /// - /// Gets information about the server. - /// - /// Information retrieved. - /// A with info about the system. - [HttpGet("Info")] - [Authorize(Policy = Policies.FirstTimeSetupOrIgnoreParentalControl)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult GetSystemInfo() + var result = files.Select(i => new LogFile { - return _appHost.GetSystemInfo(Request); - } + 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(); - /// - /// Gets public information about the server. - /// - /// Information retrieved. - /// A with public info about the system. - [HttpGet("Info/Public")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult GetPublicSystemInfo() + return result; + } + + /// + /// Gets information about the request endpoint. + /// + /// Information retrieved. + /// User does not have permission to get endpoint information. + /// with information about the endpoint. + [HttpGet("Endpoint")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public ActionResult GetEndpointInfo() + { + return new EndPointInfo { - return _appHost.GetPublicSystemInfo(Request); - } + IsLocal = HttpContext.IsLocal(), + IsInNetwork = _network.IsInLocalNetwork(HttpContext.GetNormalizedRemoteIp()) + }; + } - /// - /// Pings the system. - /// - /// Information retrieved. - /// The server name. - [HttpGet("Ping", Name = "GetPingSystem")] - [HttpPost("Ping", Name = "PostPingSystem")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult PingSystem() - { - return _appHost.Name; - } + /// + /// Gets a log file. + /// + /// The name of the log file to get. + /// Log file retrieved. + /// User does not have permission to get log files. + /// The log file. + [HttpGet("Logs/Log")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesFile(MediaTypeNames.Text.Plain)] + public ActionResult GetLogFile([FromQuery, Required] string name) + { + var file = _fileSystem.GetFiles(_appPaths.LogDirectoryPath) + .First(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase)); - /// - /// Restarts the application. - /// - /// Server restarted. - /// No content. Server restarted. - [HttpPost("Restart")] - [Authorize(Policy = Policies.LocalAccessOrRequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult RestartApplication() - { - Task.Run(async () => - { - await Task.Delay(100).ConfigureAwait(false); - _appHost.Restart(); - }); - return NoContent(); - } + // 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"); + } - /// - /// Shuts down the application. - /// - /// Server shut down. - /// No content. Server shut down. - [HttpPost("Shutdown")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult ShutdownApplication() - { - Task.Run(async () => - { - await Task.Delay(100).ConfigureAwait(false); - await _appHost.Shutdown().ConfigureAwait(false); - }); - return NoContent(); - } - - /// - /// Gets a list of available server log files. - /// - /// Information retrieved. - /// An array of with the available log files. - [HttpGet("Logs")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult GetServerLogs() - { - IEnumerable files; - - try - { - files = _fileSystem.GetFiles(_appPaths.LogDirectoryPath, new[] { ".txt", ".log" }, true, false); - } - catch (IOException ex) - { - _logger.LogError(ex, "Error getting logs"); - files = Enumerable.Empty(); - } - - 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; - } - - /// - /// Gets information about the request endpoint. - /// - /// Information retrieved. - /// with information about the endpoint. - [HttpGet("Endpoint")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult GetEndpointInfo() - { - return new EndPointInfo - { - IsLocal = HttpContext.IsLocal(), - IsInNetwork = _network.IsInLocalNetwork(HttpContext.GetNormalizedRemoteIp()) - }; - } - - /// - /// Gets a log file. - /// - /// The name of the log file to get. - /// Log file retrieved. - /// The log file. - [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"); - } - - /// - /// Gets wake on lan information. - /// - /// Information retrieved. - /// An with the WakeOnLan infos. - [HttpGet("WakeOnLanInfo")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [Obsolete("This endpoint is obsolete.")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> GetWakeOnLanInfo() - { - var result = _network.GetMacAddresses() - .Select(i => new WakeOnLanInfo(i)); - return Ok(result); - } + /// + /// Gets wake on lan information. + /// + /// Information retrieved. + /// An with the WakeOnLan infos. + [HttpGet("WakeOnLanInfo")] + [Authorize] + [Obsolete("This endpoint is obsolete.")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> GetWakeOnLanInfo() + { + var result = _network.GetMacAddresses() + .Select(i => new WakeOnLanInfo(i)); + return Ok(result); } } diff --git a/Jellyfin.Api/Controllers/TimeSyncController.cs b/Jellyfin.Api/Controllers/TimeSyncController.cs index e7c5a71257..d7304cf426 100644 --- a/Jellyfin.Api/Controllers/TimeSyncController.cs +++ b/Jellyfin.Api/Controllers/TimeSyncController.cs @@ -3,32 +3,31 @@ using MediaBrowser.Model.SyncPlay; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// The time sync controller. +/// +[Route("")] +public class TimeSyncController : BaseJellyfinApiController { /// - /// The time sync controller. + /// Gets the current UTC time. /// - [Route("")] - public class TimeSyncController : BaseJellyfinApiController + /// Time returned. + /// An to sync the client and server time. + [HttpGet("GetUtcTime")] + [ProducesResponseType(statusCode: StatusCodes.Status200OK)] + public ActionResult GetUtcTime() { - /// - /// Gets the current UTC time. - /// - /// Time returned. - /// An to sync the client and server time. - [HttpGet("GetUtcTime")] - [ProducesResponseType(statusCode: StatusCodes.Status200OK)] - public ActionResult GetUtcTime() - { - // Important to keep the following line at the beginning - var requestReceptionTime = DateTime.UtcNow; + // Important to keep the following line at the beginning + var requestReceptionTime = DateTime.UtcNow; - // Important to keep the following line at the end - var responseTransmissionTime = DateTime.UtcNow; + // Important to keep the following line at the end + var responseTransmissionTime = DateTime.UtcNow; - // Implementing NTP on such a high level results in this useless - // information being sent. On the other hand it enables future additions. - return new UtcTimeResponse(requestReceptionTime, responseTransmissionTime); - } + // Implementing NTP on such a high level results in this useless + // information being sent. On the other hand it enables future additions. + return new UtcTimeResponse(requestReceptionTime, responseTransmissionTime); } } diff --git a/Jellyfin.Api/Controllers/TrailersController.cs b/Jellyfin.Api/Controllers/TrailersController.cs index 53a839e431..b5b6406206 100644 --- a/Jellyfin.Api/Controllers/TrailersController.cs +++ b/Jellyfin.Api/Controllers/TrailersController.cs @@ -1,6 +1,4 @@ using System; -using System.Threading.Tasks; -using Jellyfin.Api.Constants; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; using MediaBrowser.Model.Dto; @@ -10,290 +8,289 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// The trailers controller. +/// +[Authorize] +public class TrailersController : BaseJellyfinApiController { + private readonly ItemsController _itemsController; + /// - /// The trailers controller. + /// Initializes a new instance of the class. /// - [Authorize(Policy = Policies.DefaultAuthorization)] - public class TrailersController : BaseJellyfinApiController + /// Instance of . + public TrailersController(ItemsController itemsController) { - private readonly ItemsController _itemsController; + _itemsController = itemsController; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of . - public TrailersController(ItemsController itemsController) - { - _itemsController = itemsController; - } + /// + /// Finds movies and trailers similar to a given trailer. + /// + /// The user id supplied as query parameter; this is required when not using an API key. + /// Optional filter by maximum official rating (PG, PG-13, TV-MA, etc). + /// Optional filter by items with theme songs. + /// Optional filter by items with theme videos. + /// Optional filter by items with subtitles. + /// Optional filter by items with special features. + /// Optional filter by items with trailers. + /// Optional. Return items that are siblings of a supplied item. + /// Optional filter by parent index number. + /// Optional filter by items that have or do not have a parental rating. + /// Optional filter by items that are HD or not. + /// Optional filter by items that are 4K or not. + /// Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimited. + /// Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimited. + /// Optional filter by items that are missing episodes or not. + /// Optional filter by items that are unaired episodes or not. + /// Optional filter by minimum community rating. + /// Optional filter by minimum critic rating. + /// Optional. The minimum premiere date. Format = ISO. + /// Optional. The minimum last saved date. Format = ISO. + /// Optional. The minimum last saved date for the current user. Format = ISO. + /// Optional. The maximum premiere date. Format = ISO. + /// Optional filter by items that have an overview or not. + /// Optional filter by items that have an IMDb id or not. + /// Optional filter by items that have a TMDb id or not. + /// Optional filter by items that have a TVDb id or not. + /// Optional filter for live tv movies. + /// Optional filter for live tv series. + /// Optional filter for live tv news. + /// Optional filter for live tv kids. + /// Optional filter for live tv sports. + /// Optional. If specified, results will be filtered by excluding item ids. This allows multiple, comma delimited. + /// Optional. The record index to start at. All items with a lower index will be dropped from the results. + /// Optional. The maximum number of records to return. + /// When searching within folders, this determines whether or not the search will be recursive. true/false. + /// Optional. Filter based on a search term. + /// Sort Order - Ascending, Descending. + /// Specify this to localize the search to a specific item or folder. Omit to use the root. + /// 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. + /// Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited. + /// Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes. + /// Optional filter by items that are marked as favorite, or not. + /// Optional filter by MediaType. Allows multiple, comma delimited. + /// Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited. + /// 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. + /// Optional filter by items that are played, or not. + /// Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited. + /// Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited. + /// Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited. + /// Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited. + /// Optional, include user data. + /// Optional, the max number of images to return, per image type. + /// Optional. The image types to include in the output. + /// Optional. If specified, results will be filtered to include only those containing the specified person. + /// Optional. If specified, results will be filtered to include only those containing the specified person id. + /// Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited. + /// Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited. + /// Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimited. + /// Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimited. + /// Optional. If specified, results will be filtered to include only those containing the specified artist id. + /// Optional. If specified, results will be filtered to include only those containing the specified album artist id. + /// Optional. If specified, results will be filtered to include only those containing the specified contributing artist id. + /// Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimited. + /// Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimited. + /// Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited. + /// Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimited. + /// Optional filter by minimum official rating (PG, PG-13, TV-MA, etc). + /// Optional filter by items that are locked. + /// Optional filter by items that are placeholders. + /// Optional filter by items that have official ratings. + /// Whether or not to hide items behind their boxsets. + /// Optional. Filter by the minimum width of the item. + /// Optional. Filter by the minimum height of the item. + /// Optional. Filter by the maximum width of the item. + /// Optional. Filter by the maximum height of the item. + /// Optional filter by items that are 3D, or not. + /// Optional filter by Series Status. Allows multiple, comma delimited. + /// Optional filter by items whose name is sorted equally or greater than a given input string. + /// Optional filter by items whose name is sorted equally than a given input string. + /// Optional filter by items whose name is equally or lesser than a given input string. + /// Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited. + /// Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited. + /// Optional. Enable the total record count. + /// Optional, include image information in output. + /// A with the trailers. + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> 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 }; - /// - /// Finds movies and trailers similar to a given trailer. - /// - /// The user id supplied as query parameter; this is required when not using an API key. - /// Optional filter by maximum official rating (PG, PG-13, TV-MA, etc). - /// Optional filter by items with theme songs. - /// Optional filter by items with theme videos. - /// Optional filter by items with subtitles. - /// Optional filter by items with special features. - /// Optional filter by items with trailers. - /// Optional. Return items that are siblings of a supplied item. - /// Optional filter by parent index number. - /// Optional filter by items that have or do not have a parental rating. - /// Optional filter by items that are HD or not. - /// Optional filter by items that are 4K or not. - /// Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimited. - /// Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimited. - /// Optional filter by items that are missing episodes or not. - /// Optional filter by items that are unaired episodes or not. - /// Optional filter by minimum community rating. - /// Optional filter by minimum critic rating. - /// Optional. The minimum premiere date. Format = ISO. - /// Optional. The minimum last saved date. Format = ISO. - /// Optional. The minimum last saved date for the current user. Format = ISO. - /// Optional. The maximum premiere date. Format = ISO. - /// Optional filter by items that have an overview or not. - /// Optional filter by items that have an IMDb id or not. - /// Optional filter by items that have a TMDb id or not. - /// Optional filter by items that have a TVDb id or not. - /// Optional filter for live tv movies. - /// Optional filter for live tv series. - /// Optional filter for live tv news. - /// Optional filter for live tv kids. - /// Optional filter for live tv sports. - /// Optional. If specified, results will be filtered by excluding item ids. This allows multiple, comma delimited. - /// Optional. The record index to start at. All items with a lower index will be dropped from the results. - /// Optional. The maximum number of records to return. - /// When searching within folders, this determines whether or not the search will be recursive. true/false. - /// Optional. Filter based on a search term. - /// Sort Order - Ascending, Descending. - /// Specify this to localize the search to a specific item or folder. Omit to use the root. - /// 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. - /// Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited. - /// Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes. - /// Optional filter by items that are marked as favorite, or not. - /// Optional filter by MediaType. Allows multiple, comma delimited. - /// Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited. - /// 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. - /// Optional filter by items that are played, or not. - /// Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited. - /// Optional, include user data. - /// Optional, the max number of images to return, per image type. - /// Optional. The image types to include in the output. - /// Optional. If specified, results will be filtered to include only those containing the specified person. - /// Optional. If specified, results will be filtered to include only those containing the specified person id. - /// Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited. - /// Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered to include only those containing the specified artist id. - /// Optional. If specified, results will be filtered to include only those containing the specified album artist id. - /// Optional. If specified, results will be filtered to include only those containing the specified contributing artist id. - /// Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimited. - /// Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited. - /// Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimited. - /// Optional filter by minimum official rating (PG, PG-13, TV-MA, etc). - /// Optional filter by items that are locked. - /// Optional filter by items that are placeholders. - /// Optional filter by items that have official ratings. - /// Whether or not to hide items behind their boxsets. - /// Optional. Filter by the minimum width of the item. - /// Optional. Filter by the minimum height of the item. - /// Optional. Filter by the maximum width of the item. - /// Optional. Filter by the maximum height of the item. - /// Optional filter by items that are 3D, or not. - /// Optional filter by Series Status. Allows multiple, comma delimited. - /// Optional filter by items whose name is sorted equally or greater than a given input string. - /// Optional filter by items whose name is sorted equally than a given input string. - /// Optional filter by items whose name is equally or lesser than a given input string. - /// Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited. - /// Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited. - /// Optional. Enable the total record count. - /// Optional, include image information in output. - /// A with the trailers. - [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> GetTrailers( - [FromQuery] Guid? userId, - [FromQuery] string? maxOfficialRating, - [FromQuery] bool? hasThemeSong, - [FromQuery] bool? hasThemeVideo, - [FromQuery] bool? hasSubtitles, - [FromQuery] bool? hasSpecialFeature, - [FromQuery] bool? hasTrailer, - [FromQuery] Guid? adjacentTo, - [FromQuery] int? parentIndexNumber, - [FromQuery] bool? hasParentalRating, - [FromQuery] bool? isHd, - [FromQuery] bool? is4K, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes, - [FromQuery] bool? isMissing, - [FromQuery] bool? isUnaired, - [FromQuery] double? minCommunityRating, - [FromQuery] double? minCriticRating, - [FromQuery] DateTime? minPremiereDate, - [FromQuery] DateTime? minDateLastSaved, - [FromQuery] DateTime? minDateLastSavedForUser, - [FromQuery] DateTime? maxPremiereDate, - [FromQuery] bool? hasOverview, - [FromQuery] bool? hasImdbId, - [FromQuery] bool? hasTmdbId, - [FromQuery] bool? hasTvdbId, - [FromQuery] bool? isMovie, - [FromQuery] bool? isSeries, - [FromQuery] bool? isNews, - [FromQuery] bool? isKids, - [FromQuery] bool? isSports, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds, - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery] bool? recursive, - [FromQuery] string? searchTerm, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, - [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, - [FromQuery] bool? isFavorite, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, - [FromQuery] bool? isPlayed, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years, - [FromQuery] bool? enableUserData, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery] string? person, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] studios, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] artists, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] artistIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumArtistIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] contributingArtistIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] albums, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] VideoType[] videoTypes, - [FromQuery] string? minOfficialRating, - [FromQuery] bool? isLocked, - [FromQuery] bool? isPlaceHolder, - [FromQuery] bool? hasOfficialRating, - [FromQuery] bool? collapseBoxSetItems, - [FromQuery] int? minWidth, - [FromQuery] int? minHeight, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] bool? is3D, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus, - [FromQuery] string? nameStartsWithOrGreater, - [FromQuery] string? nameStartsWith, - [FromQuery] string? nameLessThan, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, - [FromQuery] bool enableTotalRecordCount = true, - [FromQuery] bool? enableImages = true) - { - var includeItemTypes = new[] { BaseItemKind.Trailer }; - - return _itemsController - .GetItems( - userId, - maxOfficialRating, - hasThemeSong, - hasThemeVideo, - hasSubtitles, - hasSpecialFeature, - hasTrailer, - adjacentTo, - parentIndexNumber, - hasParentalRating, - isHd, - is4K, - locationTypes, - excludeLocationTypes, - isMissing, - isUnaired, - minCommunityRating, - minCriticRating, - minPremiereDate, - minDateLastSaved, - minDateLastSavedForUser, - maxPremiereDate, - hasOverview, - hasImdbId, - hasTmdbId, - hasTvdbId, - isMovie, - isSeries, - isNews, - isKids, - isSports, - excludeItemIds, - startIndex, - limit, - recursive, - searchTerm, - sortOrder, - parentId, - fields, - excludeItemTypes, - includeItemTypes, - filters, - isFavorite, - mediaTypes, - imageTypes, - sortBy, - isPlayed, - genres, - officialRatings, - tags, - years, - enableUserData, - imageTypeLimit, - enableImageTypes, - person, - personIds, - personTypes, - studios, - artists, - excludeArtistIds, - artistIds, - albumArtistIds, - contributingArtistIds, - albums, - albumIds, - ids, - videoTypes, - minOfficialRating, - isLocked, - isPlaceHolder, - hasOfficialRating, - collapseBoxSetItems, - minWidth, - minHeight, - maxWidth, - maxHeight, - is3D, - seriesStatus, - nameStartsWithOrGreater, - nameStartsWith, - nameLessThan, - studioIds, - genreIds, - enableTotalRecordCount, - enableImages); - } + return _itemsController + .GetItems( + userId, + maxOfficialRating, + hasThemeSong, + hasThemeVideo, + hasSubtitles, + hasSpecialFeature, + hasTrailer, + adjacentTo, + parentIndexNumber, + hasParentalRating, + isHd, + is4K, + locationTypes, + excludeLocationTypes, + isMissing, + isUnaired, + minCommunityRating, + minCriticRating, + minPremiereDate, + minDateLastSaved, + minDateLastSavedForUser, + maxPremiereDate, + hasOverview, + hasImdbId, + hasTmdbId, + hasTvdbId, + isMovie, + isSeries, + isNews, + isKids, + isSports, + excludeItemIds, + startIndex, + limit, + recursive, + searchTerm, + sortOrder, + parentId, + fields, + excludeItemTypes, + includeItemTypes, + filters, + isFavorite, + mediaTypes, + imageTypes, + sortBy, + isPlayed, + genres, + officialRatings, + tags, + years, + enableUserData, + imageTypeLimit, + enableImageTypes, + person, + personIds, + personTypes, + studios, + artists, + excludeArtistIds, + artistIds, + albumArtistIds, + contributingArtistIds, + albums, + albumIds, + ids, + videoTypes, + minOfficialRating, + isLocked, + isPlaceHolder, + hasOfficialRating, + collapseBoxSetItems, + minWidth, + minHeight, + maxWidth, + maxHeight, + is3D, + seriesStatus, + nameStartsWithOrGreater, + nameStartsWith, + nameLessThan, + studioIds, + genreIds, + enableTotalRecordCount, + enableImages); } } diff --git a/Jellyfin.Api/Controllers/TvShowsController.cs b/Jellyfin.Api/Controllers/TvShowsController.cs index 7f4f4d0776..7d23281f2c 100644 --- a/Jellyfin.Api/Controllers/TvShowsController.cs +++ b/Jellyfin.Api/Controllers/TvShowsController.cs @@ -2,8 +2,8 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; -using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; using Jellyfin.Extensions; @@ -19,366 +19,369 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// The tv shows controller. +/// +[Route("Shows")] +[Authorize] +public class TvShowsController : BaseJellyfinApiController { + private readonly IUserManager _userManager; + private readonly ILibraryManager _libraryManager; + private readonly IDtoService _dtoService; + private readonly ITVSeriesManager _tvSeriesManager; + /// - /// The tv shows controller. + /// Initializes a new instance of the class. /// - [Route("Shows")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class TvShowsController : BaseJellyfinApiController + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + 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; + _userManager = userManager; + _libraryManager = libraryManager; + _dtoService = dtoService; + _tvSeriesManager = tvSeriesManager; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - public TvShowsController( - IUserManager userManager, - ILibraryManager libraryManager, - IDtoService dtoService, - ITVSeriesManager tvSeriesManager) - { - _userManager = userManager; - _libraryManager = libraryManager; - _dtoService = dtoService; - _tvSeriesManager = tvSeriesManager; - } + /// + /// Gets a list of next up episodes. + /// + /// The user id of the user to get the next up episodes for. + /// Optional. The record index to start at. All items with a lower index will be dropped from the results. + /// Optional. The maximum number of records to return. + /// Optional. Specify additional fields of information to return in the output. + /// Optional. Filter by series id. + /// Optional. Specify this to localize the search to a specific item or folder. Omit to use the root. + /// Optional. Include image information in output. + /// Optional. The max number of images to return, per image type. + /// Optional. The image types to include in the output. + /// Optional. Include user data. + /// Optional. Starting date of shows to show in Next Up section. + /// Whether to enable the total records count. Defaults to true. + /// Whether to disable sending the first episode in a series as next up. + /// Whether to include watched episode in next up results. + /// A with the next up episodes. + [HttpGet("NextUp")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> 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); - /// - /// Gets a list of next up episodes. - /// - /// The user id of the user to get the next up episodes for. - /// Optional. The record index to start at. All items with a lower index will be dropped from the results. - /// Optional. The maximum number of records to return. - /// Optional. Specify additional fields of information to return in the output. - /// Optional. Filter by series id. - /// Optional. Specify this to localize the search to a specific item or folder. Omit to use the root. - /// Optional. Include image information in output. - /// Optional. The max number of images to return, per image type. - /// Optional. The image types to include in the output. - /// Optional. Include user data. - /// Optional. Starting date of shows to show in Next Up section. - /// Whether to enable the total records count. Defaults to true. - /// Whether to disable sending the first episode in a series as next up. - /// Whether to include watched episode in next up results. - /// A with the next up episodes. - [HttpGet("NextUp")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> 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( - startIndex, - result.TotalRecordCount, - returnItems); - } - - /// - /// Gets a list of upcoming episodes. - /// - /// The user id of the user to get the upcoming episodes for. - /// Optional. The record index to start at. All items with a lower index will be dropped from the results. - /// Optional. The maximum number of records to return. - /// Optional. Specify additional fields of information to return in the output. - /// Optional. Specify this to localize the search to a specific item or folder. Omit to use the root. - /// Optional. Include image information in output. - /// Optional. The max number of images to return, per image type. - /// Optional. The image types to include in the output. - /// Optional. Include user data. - /// A with the next up episodes. - [HttpGet("Upcoming")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> 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; - - 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 returnItems = _dtoService.GetBaseItemDtos(itemsResult, options, user); + var user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); - return new QueryResult( - startIndex, - itemsResult.Count, - returnItems); - } + var returnItems = _dtoService.GetBaseItemDtos(result.Items, options, user); - /// - /// Gets episodes for a tv season. - /// - /// The series id. - /// The user id. - /// 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. - /// Optional filter by season number. - /// Optional. Filter by season id. - /// Optional. Filter by items that are missing episodes or not. - /// Optional. Return items that are siblings of a supplied item. - /// Optional. Skip through the list until a given item is found. - /// Optional. The record index to start at. All items with a lower index will be dropped from the results. - /// Optional. The maximum number of records to return. - /// Optional, include image information in output. - /// Optional, the max number of images to return, per image type. - /// Optional. The image types to include in the output. - /// Optional. Include user data. - /// 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. - /// A with the episodes on success or a if the series was not found. - [HttpGet("{seriesId}/Episodes")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult> 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) + return new QueryResult( + startIndex, + result.TotalRecordCount, + returnItems); + } + + /// + /// Gets a list of upcoming episodes. + /// + /// The user id of the user to get the upcoming episodes for. + /// Optional. The record index to start at. All items with a lower index will be dropped from the results. + /// Optional. The maximum number of records to return. + /// Optional. Specify additional fields of information to return in the output. + /// Optional. Specify this to localize the search to a specific item or folder. Omit to use the root. + /// Optional. Include image information in output. + /// Optional. The max number of images to return, per image type. + /// Optional. The image types to include in the output. + /// Optional. Include user data. + /// A with the next up episodes. + [HttpGet("Upcoming")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> 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); + + var minPremiereDate = DateTime.UtcNow.Date.AddDays(-1); + + var parentIdGuid = parentId ?? Guid.Empty; + + var options = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + + var itemsResult = _libraryManager.GetItemList(new InternalItemsQuery(user) { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); + 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 + }); - List episodes; + var returnItems = _dtoService.GetBaseItemDtos(itemsResult, options, user); - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + return new QueryResult( + 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); - } + /// + /// Gets episodes for a tv season. + /// + /// The series id. + /// The user id. + /// 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. + /// Optional filter by season number. + /// Optional. Filter by season id. + /// Optional. Filter by items that are missing episodes or not. + /// Optional. Return items that are siblings of a supplied item. + /// Optional. Skip through the list until a given item is found. + /// Optional. The record index to start at. All items with a lower index will be dropped from the results. + /// Optional. The maximum number of records to return. + /// Optional, include image information in output. + /// Optional, the max number of images to return, per image type. + /// Optional. The image types to include in the output. + /// Optional. Include user data. + /// 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. + /// A with the episodes on success or a if the series was not found. + [HttpGet("{seriesId}/Episodes")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult> 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"); - } + List episodes; - var seasonItem = series - .GetSeasons(user, dtoOptions) - .FirstOrDefault(i => i.IndexNumber == season.Value); + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - episodes = seasonItem is null ? - new List() - : ((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"); - } - - episodes = series.GetEpisodes(user, dtoOptions).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(); - } - - if (startItemId.HasValue) - { - episodes = episodes - .SkipWhile(i => !startItemId.Value.Equals(i.Id)) - .ToList(); - } - - // This must be the last filter - if (adjacentTo.HasValue && !adjacentTo.Value.Equals(default)) - { - episodes = UserViewBuilder.FilterForAdjacency(episodes, adjacentTo.Value).ToList(); - } - - if (string.Equals(sortBy, ItemSortBy.Random, StringComparison.OrdinalIgnoreCase)) - { - episodes.Shuffle(); - } - - var returnItems = episodes; - - if (startIndex.HasValue || limit.HasValue) - { - returnItems = ApplyPaging(episodes, startIndex, limit).ToList(); - } - - var dtos = _dtoService.GetBaseItemDtos(returnItems, dtoOptions, user); - - return new QueryResult( - startIndex, - episodes.Count, - dtos); - } - - /// - /// Gets seasons for a tv series. - /// - /// The series id. - /// The user id. - /// 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. - /// Optional. Filter by special season. - /// Optional. Filter by items that are missing episodes or not. - /// Optional. Return items that are siblings of a supplied item. - /// Optional. Include image information in output. - /// Optional. The max number of images to return, per image type. - /// Optional. The image types to include in the output. - /// Optional. Include user data. - /// A on success or a if the series was not found. - [HttpGet("{seriesId}/Seasons")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult> 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 (seasonId.HasValue) // Season id was supplied. Get episodes by season id. { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); + var item = _libraryManager.GetItemById(seasonId.Value); + if (item is not Season seasonItem) + { + return NotFound("No season exists with Id " + seasonId); + } + 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 seasons = series.GetItemList(new InternalItemsQuery(user) - { - IsMissing = isMissing, - IsSpecialSeason = isSpecialSeason, - AdjacentTo = adjacentTo - }); + var seasonItem = series + .GetSeasons(user, dtoOptions) + .FirstOrDefault(i => i.IndexNumber == season.Value); - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - - var returnItems = _dtoService.GetBaseItemDtos(seasons, dtoOptions, user); - - return new QueryResult(returnItems); + episodes = seasonItem is null ? + new List() + : ((Season)seasonItem).GetEpisodes(user, dtoOptions); } - - /// - /// Applies the paging. - /// - /// The items. - /// The start index. - /// The limit. - /// IEnumerable{BaseItem}. - private IEnumerable ApplyPaging(IEnumerable items, int? startIndex, int? limit) + else // No season number or season id was supplied. Returning all episodes. { - // Start at - if (startIndex.HasValue) + if (_libraryManager.GetItemById(seriesId) is not Series series) { - items = items.Skip(startIndex.Value); + return NotFound("Series not found"); } - // Return limit - if (limit.HasValue) - { - items = items.Take(limit.Value); - } - - return items; + episodes = series.GetEpisodes(user, dtoOptions).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(); + } + + if (startItemId.HasValue) + { + episodes = episodes + .SkipWhile(i => !startItemId.Value.Equals(i.Id)) + .ToList(); + } + + // This must be the last filter + if (adjacentTo.HasValue && !adjacentTo.Value.Equals(default)) + { + episodes = UserViewBuilder.FilterForAdjacency(episodes, adjacentTo.Value).ToList(); + } + + if (string.Equals(sortBy, ItemSortBy.Random, StringComparison.OrdinalIgnoreCase)) + { + episodes.Shuffle(); + } + + var returnItems = episodes; + + if (startIndex.HasValue || limit.HasValue) + { + returnItems = ApplyPaging(episodes, startIndex, limit).ToList(); + } + + var dtos = _dtoService.GetBaseItemDtos(returnItems, dtoOptions, user); + + return new QueryResult( + startIndex, + episodes.Count, + dtos); + } + + /// + /// Gets seasons for a tv series. + /// + /// The series id. + /// The user id. + /// 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. + /// Optional. Filter by special season. + /// Optional. Filter by items that are missing episodes or not. + /// Optional. Return items that are siblings of a supplied item. + /// Optional. Include image information in output. + /// Optional. The max number of images to return, per image type. + /// Optional. The image types to include in the output. + /// Optional. Include user data. + /// A on success or a if the series was not found. + [HttpGet("{seriesId}/Seasons")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult> 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"); + } + + var seasons = series.GetItemList(new InternalItemsQuery(user) + { + IsMissing = isMissing, + IsSpecialSeason = isSpecialSeason, + AdjacentTo = adjacentTo + }); + + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + + var returnItems = _dtoService.GetBaseItemDtos(seasons, dtoOptions, user); + + return new QueryResult(returnItems); + } + + /// + /// Applies the paging. + /// + /// The items. + /// The start index. + /// The limit. + /// IEnumerable{BaseItem}. + private IEnumerable ApplyPaging(IEnumerable items, int? startIndex, int? limit) + { + // Start at + if (startIndex.HasValue) + { + items = items.Skip(startIndex.Value); + } + + // Return limit + if (limit.HasValue) + { + items = items.Take(limit.Value); + } + + return items; } } diff --git a/Jellyfin.Api/Controllers/UniversalAudioController.cs b/Jellyfin.Api/Controllers/UniversalAudioController.cs index d77126a353..2e9035d24f 100644 --- a/Jellyfin.Api/Controllers/UniversalAudioController.cs +++ b/Jellyfin.Api/Controllers/UniversalAudioController.cs @@ -5,8 +5,6 @@ using System.Globalization; using System.Linq; using System.Threading.Tasks; using Jellyfin.Api.Attributes; -using Jellyfin.Api.Constants; -using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.StreamingDtos; @@ -20,197 +18,160 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Controllers -{ - /// - /// The universal audio controller. - /// - [Route("")] - public class UniversalAudioController : BaseJellyfinApiController - { - private readonly ILibraryManager _libraryManager; - private readonly ILogger _logger; - private readonly MediaInfoHelper _mediaInfoHelper; - private readonly AudioHelper _audioHelper; - private readonly DynamicHlsHelper _dynamicHlsHelper; +namespace Jellyfin.Api.Controllers; - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - /// Instance of . - /// Instance of . - /// Instance of . - public UniversalAudioController( - ILibraryManager libraryManager, - ILogger logger, - MediaInfoHelper mediaInfoHelper, - AudioHelper audioHelper, - DynamicHlsHelper dynamicHlsHelper) +/// +/// The universal audio controller. +/// +[Route("")] +public class UniversalAudioController : BaseJellyfinApiController +{ + private readonly ILibraryManager _libraryManager; + private readonly ILogger _logger; + private readonly MediaInfoHelper _mediaInfoHelper; + private readonly AudioHelper _audioHelper; + private readonly DynamicHlsHelper _dynamicHlsHelper; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + /// Instance of . + /// Instance of . + /// Instance of . + public UniversalAudioController( + ILibraryManager libraryManager, + ILogger logger, + MediaInfoHelper mediaInfoHelper, + AudioHelper audioHelper, + DynamicHlsHelper dynamicHlsHelper) + { + _libraryManager = libraryManager; + _logger = logger; + _mediaInfoHelper = mediaInfoHelper; + _audioHelper = audioHelper; + _dynamicHlsHelper = dynamicHlsHelper; + } + + /// + /// Gets an audio stream. + /// + /// The item id. + /// Optional. The audio container. + /// The media version id, if playing an alternate version. + /// The device id of the client requesting. Used to stop encoding processes when needed. + /// Optional. The user id. + /// Optional. The audio codec to transcode to. + /// Optional. The maximum number of audio channels. + /// Optional. The number of how many audio channels to transcode to. + /// Optional. The maximum streaming bitrate. + /// Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults. + /// Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms. + /// Optional. The container to transcode to. + /// Optional. The transcoding protocol. + /// Optional. The maximum audio sample rate. + /// Optional. The maximum audio bit depth. + /// Optional. Whether to enable remote media. + /// Optional. Whether to break on non key frames. + /// Whether to enable redirection. Defaults to true. + /// Audio stream returned. + /// Redirected to remote audio stream. + /// A containing the audio file. + [HttpGet("Audio/{itemId}/universal")] + [HttpHead("Audio/{itemId}/universal", Name = "HeadUniversalAudioStream")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status302Found)] + [ProducesAudioFile] + public async Task 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); + + var info = await _mediaInfoHelper.GetPlaybackInfo( + itemId, + userId, + mediaSourceId) + .ConfigureAwait(false); + + // set device specific data + var item = _libraryManager.GetItemById(itemId); + + foreach (var sourceInfo in info.MediaSources) { - _libraryManager = libraryManager; - _logger = logger; - _mediaInfoHelper = mediaInfoHelper; - _audioHelper = audioHelper; - _dynamicHlsHelper = dynamicHlsHelper; + _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()); } - /// - /// Gets an audio stream. - /// - /// The item id. - /// Optional. The audio container. - /// The media version id, if playing an alternate version. - /// The device id of the client requesting. Used to stop encoding processes when needed. - /// Optional. The user id. - /// Optional. The audio codec to transcode to. - /// Optional. The maximum number of audio channels. - /// Optional. The number of how many audio channels to transcode to. - /// Optional. The maximum streaming bitrate. - /// Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults. - /// Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms. - /// Optional. The container to transcode to. - /// Optional. The transcoding protocol. - /// Optional. The maximum audio sample rate. - /// Optional. The maximum audio bit depth. - /// Optional. Whether to enable remote media. - /// Optional. Whether to break on non key frames. - /// Whether to enable redirection. Defaults to true. - /// Audio stream returned. - /// Redirected to remote audio stream. - /// A containing the audio file. - [HttpGet("Audio/{itemId}/universal")] - [HttpHead("Audio/{itemId}/universal", Name = "HeadUniversalAudioStream")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status302Found)] - [ProducesAudioFile] - public async Task 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) + _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate); + + foreach (var source in info.MediaSources) { - var deviceProfile = GetDeviceProfile(container, transcodingContainer, audioCodec, transcodingProtocol, breakOnNonKeyFrames, transcodingAudioChannels, maxAudioSampleRate, maxAudioBitDepth, maxAudioChannels); + _mediaInfoHelper.NormalizeMediaSourceContainer(source, deviceProfile, DlnaProfileType.Video); + } - if (!userId.HasValue || userId.Value.Equals(default)) - { - userId = User.GetUserId(); - } + var mediaSource = info.MediaSources[0]; + if (mediaSource.SupportsDirectPlay && mediaSource.Protocol == MediaProtocol.Http && enableRedirection && mediaSource.IsRemote && enableRemoteMedia.HasValue && enableRemoteMedia.Value) + { + return Redirect(mediaSource.Path); + } - _logger.LogInformation("GetPostedPlaybackInfo profile: {@Profile}", deviceProfile); + 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 info = await _mediaInfoHelper.GetPlaybackInfo( - itemId, - userId, - mediaSourceId) - .ConfigureAwait(false); - - // 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); - } - - var mediaSource = info.MediaSources[0]; - if (mediaSource.SupportsDirectPlay && mediaSource.Protocol == MediaProtocol.Http && enableRedirection && mediaSource.IsRemote && enableRemoteMedia.HasValue && enableRemoteMedia.Value) - { - return Redirect(mediaSource.Path); - } - - 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 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(), - EnableAdaptiveBitrateStreaming = true - }; - - return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType.Hls, dynamicHlsRequestDto, true) - .ConfigureAwait(false); - } - - var audioStreamingDto = new StreamingRequestDto + var dynamicHlsRequestDto = new HlsAudioRequestDto { Id = itemId, - Container = isStatic ? null : ("." + mediaSource.TranscodingContainer), + Container = ".m3u8", Static = isStatic, PlaySessionId = info.PlaySessionId, + // fallback to mpegts if device reports some weird value unsupported by hls + SegmentContainer = Array.Exists(supportedHlsContainers, element => element == transcodingContainer) ? transcodingContainer : "ts", MediaSourceId = mediaSourceId, DeviceId = deviceId, AudioCodec = audioCodec, @@ -220,121 +181,153 @@ namespace Jellyfin.Api.Controllers BreakOnNonKeyFrames = breakOnNonKeyFrames, AudioSampleRate = maxAudioSampleRate, MaxAudioChannels = maxAudioChannels, - AudioBitRate = isStatic ? null : (audioBitRate ?? maxStreamingBitrate), MaxAudioBitDepth = maxAudioBitDepth, - AudioChannels = maxAudioChannels, - CopyTimestamps = true, + AudioBitRate = audioBitRate ?? maxStreamingBitrate, StartTimeTicks = startTimeTicks, - SubtitleMethod = SubtitleDeliveryMethod.Embed, + SubtitleMethod = SubtitleDeliveryMethod.Hls, + RequireAvc = false, + DeInterlace = false, + RequireNonAnamorphic = false, + EnableMpegtsM2TsMode = false, TranscodeReasons = mediaSource.TranscodeReasons == 0 ? null : mediaSource.TranscodeReasons.ToString(), - Context = EncodingContext.Static + Context = EncodingContext.Static, + StreamOptions = new Dictionary(), + 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++) + return await _audioHelper.GetAudioStream(TranscodingJobType.Progressive, audioStreamingDto).ConfigureAwait(false); + } + + 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(); + + int len = containers.Length; + var directPlayProfiles = new DirectPlayProfile[len]; + for (int i = 0; i < len; i++) + { + var parts = containers[i].Split('|', StringSplitOptions.RemoveEmptyEntries); + + var audioCodecs = parts.Length == 1 ? null : string.Join(',', parts.Skip(1)); + + directPlayProfiles[i] = new DirectPlayProfile { - var parts = containers[i].Split('|', StringSplitOptions.RemoveEmptyEntries); - - var audioCodecs = parts.Length == 1 ? null : string.Join(',', parts.Skip(1)); - - directPlayProfiles[i] = new DirectPlayProfile - { - Type = DlnaProfileType.Audio, - Container = parts[0], - AudioCodec = audioCodecs - }; - } - - deviceProfile.DirectPlayProfiles = directPlayProfiles; - - deviceProfile.TranscodingProfiles = new[] - { - 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(); - var conditions = new List(); - - if (maxAudioSampleRate.HasValue) - { - // codec profile - conditions.Add( - new ProfileCondition - { - Condition = ProfileConditionType.LessThanEqual, - IsRequired = false, - Property = ProfileConditionValue.AudioSampleRate, - Value = maxAudioSampleRate.Value.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) - }); - } - - if (maxAudioChannels.HasValue) - { - // codec profile - conditions.Add( - new ProfileCondition - { - Condition = ProfileConditionType.LessThanEqual, - IsRequired = false, - Property = ProfileConditionValue.AudioChannels, - Value = maxAudioChannels.Value.ToString(CultureInfo.InvariantCulture) - }); - } - - 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; } + + deviceProfile.DirectPlayProfiles = directPlayProfiles; + + deviceProfile.TranscodingProfiles = new[] + { + 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) + } + }; + + var codecProfiles = new List(); + var conditions = new List(); + + if (maxAudioSampleRate.HasValue) + { + // codec profile + conditions.Add( + new ProfileCondition + { + Condition = ProfileConditionType.LessThanEqual, + IsRequired = false, + Property = ProfileConditionValue.AudioSampleRate, + Value = maxAudioSampleRate.Value.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) + }); + } + + if (maxAudioChannels.HasValue) + { + // codec profile + conditions.Add( + new ProfileCondition + { + Condition = ProfileConditionType.LessThanEqual, + IsRequired = false, + Property = ProfileConditionValue.AudioChannels, + Value = maxAudioChannels.Value.ToString(CultureInfo.InvariantCulture) + }); + } + + if (conditions.Count > 0) + { + // codec profile + codecProfiles.Add( + new CodecProfile + { + Type = CodecType.Audio, + Container = string.Join(',', containers), + Conditions = conditions.ToArray() + }); + } + + deviceProfile.CodecProfiles = codecProfiles.ToArray(); + + return deviceProfile; } } diff --git a/Jellyfin.Api/Controllers/UserController.cs b/Jellyfin.Api/Controllers/UserController.cs index 568224a424..530bd96031 100644 --- a/Jellyfin.Api/Controllers/UserController.cs +++ b/Jellyfin.Api/Controllers/UserController.cs @@ -15,6 +15,7 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Net; +using MediaBrowser.Controller.Playlists; using MediaBrowser.Controller.QuickConnect; using MediaBrowser.Controller.Session; using MediaBrowser.Model.Configuration; @@ -25,564 +26,561 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// User controller. +/// +[Route("Users")] +public class UserController : BaseJellyfinApiController { + private readonly IUserManager _userManager; + private readonly ISessionManager _sessionManager; + private readonly INetworkManager _networkManager; + private readonly IDeviceManager _deviceManager; + private readonly IAuthorizationContext _authContext; + private readonly IServerConfigurationManager _config; + private readonly ILogger _logger; + private readonly IQuickConnect _quickConnectManager; + private readonly IPlaylistManager _playlistManager; + /// - /// User controller. + /// Initializes a new instance of the class. /// - [Route("Users")] - public class UserController : BaseJellyfinApiController + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + public UserController( + IUserManager userManager, + ISessionManager sessionManager, + INetworkManager networkManager, + IDeviceManager deviceManager, + IAuthorizationContext authContext, + IServerConfigurationManager config, + ILogger logger, + IQuickConnect quickConnectManager, + IPlaylistManager playlistManager) { - 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; + _userManager = userManager; + _sessionManager = sessionManager; + _networkManager = networkManager; + _deviceManager = deviceManager; + _authContext = authContext; + _config = config; + _logger = logger; + _quickConnectManager = quickConnectManager; + _playlistManager = playlistManager; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - public UserController( - IUserManager userManager, - ISessionManager sessionManager, - INetworkManager networkManager, - IDeviceManager deviceManager, - IAuthorizationContext authContext, - IServerConfigurationManager config, - ILogger logger, - IQuickConnect quickConnectManager) + /// + /// Gets a list of users. + /// + /// Optional filter by IsHidden=true or false. + /// Optional filter by IsDisabled=true or false. + /// Users returned. + /// An containing the users. + [HttpGet] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> GetUsers( + [FromQuery] bool? isHidden, + [FromQuery] bool? isDisabled) + { + var users = Get(isHidden, isDisabled, false, false); + return Ok(users); + } + + /// + /// Gets a list of publicly visible users for display on a login screen. + /// + /// Public users returned. + /// An containing the public users. + [HttpGet("Public")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> GetPublicUsers() + { + // If the startup wizard hasn't been completed then just return all users + if (!_config.Configuration.IsStartupWizardCompleted) { - _userManager = userManager; - _sessionManager = sessionManager; - _networkManager = networkManager; - _deviceManager = deviceManager; - _authContext = authContext; - _config = config; - _logger = logger; - _quickConnectManager = quickConnectManager; + return Ok(Get(false, false, false, false)); } - /// - /// Gets a list of users. - /// - /// Optional filter by IsHidden=true or false. - /// Optional filter by IsDisabled=true or false. - /// Users returned. - /// An containing the users. - [HttpGet] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> GetUsers( - [FromQuery] bool? isHidden, - [FromQuery] bool? isDisabled) + return Ok(Get(false, false, true, true)); + } + + /// + /// Gets a user by Id. + /// + /// The user id. + /// User returned. + /// User not found. + /// An with information about the user or a if the user was not found. + [HttpGet("{userId}")] + [Authorize(Policy = Policies.IgnoreParentalControl)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult GetUserById([FromRoute, Required] Guid userId) + { + var user = _userManager.GetUserById(userId); + + if (user is null) { - var users = Get(isHidden, isDisabled, false, false); - return Ok(users); + return NotFound("User not found"); } - /// - /// Gets a list of publicly visible users for display on a login screen. - /// - /// Public users returned. - /// An containing the public users. - [HttpGet("Public")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> 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)); - } + var result = _userManager.GetUserDto(user, HttpContext.GetNormalizedRemoteIp().ToString()); + return result; + } - return Ok(Get(false, false, true, true)); + /// + /// Deletes a user. + /// + /// The user id. + /// User deleted. + /// User not found. + /// A indicating success or a if the user was not found. + [HttpDelete("{userId}")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeleteUser([FromRoute, Required] Guid userId) + { + var user = _userManager.GetUserById(userId); + if (user is null) + { + return NotFound(); } - /// - /// Gets a user by Id. - /// - /// The user id. - /// User returned. - /// User not found. - /// An with information about the user or a if the user was not found. - [HttpGet("{userId}")] - [Authorize(Policy = Policies.IgnoreParentalControl)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult GetUserById([FromRoute, Required] Guid userId) + await _sessionManager.RevokeUserTokens(user.Id, null).ConfigureAwait(false); + await _playlistManager.RemovePlaylistsAsync(userId).ConfigureAwait(false); + await _userManager.DeleteUserAsync(userId).ConfigureAwait(false); + return NoContent(); + } + + /// + /// Authenticates a user. + /// + /// The user id. + /// The password as plain text. + /// User authenticated. + /// Sha1-hashed password only is not allowed. + /// User not found. + /// A containing an . + [HttpPost("{userId}/Authenticate")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Obsolete("Authenticate with username instead")] + public async Task> AuthenticateUser( + [FromRoute, Required] Guid userId, + [FromQuery, Required] string pw) + { + var user = _userManager.GetUserById(userId); + + if (user is null) { - var user = _userManager.GetUserById(userId); - - if (user is null) - { - return NotFound("User not found"); - } - - var result = _userManager.GetUserDto(user, HttpContext.GetNormalizedRemoteIp().ToString()); - return result; + return NotFound("User not found"); } - /// - /// Deletes a user. - /// - /// The user id. - /// User deleted. - /// User not found. - /// A indicating success or a if the user was not found. - [HttpDelete("{userId}")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task DeleteUser([FromRoute, Required] Guid userId) + AuthenticateUserByName request = new AuthenticateUserByName { - var user = _userManager.GetUserById(userId); - await _sessionManager.RevokeUserTokens(user.Id, null).ConfigureAwait(false); - await _userManager.DeleteUserAsync(userId).ConfigureAwait(false); - return NoContent(); - } + Username = user.Username, + Pw = pw + }; + return await AuthenticateUserByName(request).ConfigureAwait(false); + } - /// - /// Authenticates a user. - /// - /// The user id. - /// The password as plain text. - /// User authenticated. - /// Sha1-hashed password only is not allowed. - /// User not found. - /// A containing an . - [HttpPost("{userId}/Authenticate")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [Obsolete("Authenticate with username instead")] - public async Task> AuthenticateUser( - [FromRoute, Required] Guid userId, - [FromQuery, Required] string pw) + /// + /// Authenticates a user by name. + /// + /// The request. + /// User authenticated. + /// A containing an with information about the new session. + [HttpPost("AuthenticateByName")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> AuthenticateUserByName([FromBody, Required] AuthenticateUserByName request) + { + var auth = await _authContext.GetAuthorizationInfo(Request).ConfigureAwait(false); + + try { - var user = _userManager.GetUserById(userId); - - if (user is null) + var result = await _sessionManager.AuthenticateNewSession(new AuthenticationRequest { - return NotFound("User not found"); - } - - AuthenticateUserByName request = new AuthenticateUserByName - { - Username = user.Username, - Pw = pw - }; - return await AuthenticateUserByName(request).ConfigureAwait(false); - } - - /// - /// Authenticates a user by name. - /// - /// The request. - /// User authenticated. - /// A containing an with information about the new session. - [HttpPost("AuthenticateByName")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> AuthenticateUserByName([FromBody, Required] AuthenticateUserByName request) - { - var auth = await _authContext.GetAuthorizationInfo(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) - { - // rethrow adding IP address to message - throw new SecurityException($"[{HttpContext.GetNormalizedRemoteIp()}] {e.Message}", e); - } - } - - /// - /// Authenticates a user with quick connect. - /// - /// The request. - /// User authenticated. - /// Missing token. - /// A containing an with information about the new session. - [HttpPost("AuthenticateWithQuickConnect")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult AuthenticateWithQuickConnect([FromBody, Required] QuickConnectDto request) - { - try - { - return _quickConnectManager.GetAuthorizedRequest(request.Secret); - } - catch (SecurityException e) - { - // rethrow adding IP address to message - throw new SecurityException($"[{HttpContext.GetNormalizedRemoteIp()}] {e.Message}", e); - } - } - - /// - /// Updates a user's password. - /// - /// The user id. - /// The request. - /// Password successfully reset. - /// User is not allowed to update the password. - /// User not found. - /// A indicating success or a or a on failure. - [HttpPost("{userId}/Password")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task 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); - - if (user is null) - { - return NotFound("User not found"); - } - - if (request.ResetPassword) - { - await _userManager.ResetPassword(user).ConfigureAwait(false); - } - else - { - if (!User.IsInRole(UserRoles.Administrator)) - { - var success = await _userManager.AuthenticateUser( - user.Username, - request.CurrentPw, - request.CurrentPw, - HttpContext.GetNormalizedRemoteIp().ToString(), - false).ConfigureAwait(false); - - if (success is null) - { - return StatusCode(StatusCodes.Status403Forbidden, "Invalid user or password entered."); - } - } - - await _userManager.ChangePassword(user, request.NewPw).ConfigureAwait(false); - - var currentToken = User.GetToken(); - - await _sessionManager.RevokeUserTokens(user.Id, currentToken).ConfigureAwait(false); - } - - return NoContent(); - } - - /// - /// Updates a user's easy password. - /// - /// The user id. - /// The request. - /// Password successfully reset. - /// User is not allowed to update the password. - /// User not found. - /// A indicating success or a or a on failure. - [HttpPost("{userId}/EasyPassword")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task 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."); - } - - var user = _userManager.GetUserById(userId); - - if (user is null) - { - return NotFound("User not found"); - } - - if (request.ResetPassword) - { - await _userManager.ResetEasyPassword(user).ConfigureAwait(false); - } - else - { - await _userManager.ChangeEasyPassword(user, request.NewPw, request.NewPassword).ConfigureAwait(false); - } - - return NoContent(); - } - - /// - /// Updates a user. - /// - /// The user id. - /// The updated user model. - /// User updated. - /// User information was not supplied. - /// User update forbidden. - /// A indicating success or a or a on failure. - [HttpPost("{userId}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - public async Task UpdateUser( - [FromRoute, Required] Guid userId, - [FromBody, Required] UserDto updateUser) - { - if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true)) - { - return StatusCode(StatusCodes.Status403Forbidden, "User update not allowed."); - } - - var user = _userManager.GetUserById(userId); - - if (!string.Equals(user.Username, updateUser.Name, StringComparison.Ordinal)) - { - await _userManager.RenameUser(user, updateUser.Name).ConfigureAwait(false); - } - - await _userManager.UpdateConfigurationAsync(user.Id, updateUser.Configuration).ConfigureAwait(false); - - return NoContent(); - } - - /// - /// Updates a user policy. - /// - /// The user id. - /// The new user policy. - /// User policy updated. - /// User policy was not supplied. - /// User policy update forbidden. - /// A indicating success or a or a on failure.. - [HttpPost("{userId}/Policy")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - public async Task 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."); - } - } - - // If disabling - if (newPolicy.IsDisabled && user.HasPermission(PermissionKind.IsAdministrator)) - { - return StatusCode(StatusCodes.Status403Forbidden, "Administrators cannot be disabled."); - } - - // 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."); - } - - var currentToken = User.GetToken(); - await _sessionManager.RevokeUserTokens(user.Id, currentToken).ConfigureAwait(false); - } - - await _userManager.UpdatePolicyAsync(userId, newPolicy).ConfigureAwait(false); - - return NoContent(); - } - - /// - /// Updates a user configuration. - /// - /// The user id. - /// The new user configuration. - /// User configuration updated. - /// User configuration update forbidden. - /// A indicating success. - [HttpPost("{userId}/Configuration")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - public async Task UpdateUserConfiguration( - [FromRoute, Required] Guid userId, - [FromBody, Required] UserConfiguration userConfig) - { - if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true)) - { - return StatusCode(StatusCodes.Status403Forbidden, "User configuration update not allowed"); - } - - await _userManager.UpdateConfigurationAsync(userId, userConfig).ConfigureAwait(false); - - return NoContent(); - } - - /// - /// Creates a user. - /// - /// The create user by name request body. - /// User created. - /// An of the new user. - [HttpPost("New")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> 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); - } - - var result = _userManager.GetUserDto(newUser, HttpContext.GetNormalizedRemoteIp().ToString()); + 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; } - - /// - /// Initiates the forgot password process for a local user. - /// - /// The forgot password request containing the entered username. - /// Password reset process started. - /// A containing a . - [HttpPost("ForgotPassword")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> ForgotPassword([FromBody, Required] ForgotPasswordDto forgotPasswordRequest) + catch (SecurityException e) { - var ip = HttpContext.GetNormalizedRemoteIp(); - var isLocal = HttpContext.IsLocal() - || _networkManager.IsInLocalNetwork(ip); - - if (isLocal) - { - _logger.LogWarning("Password reset process initiated from outside the local network with IP: {IP}", ip); - } - - var result = await _userManager.StartForgotPasswordProcess(forgotPasswordRequest.EnteredUsername, isLocal).ConfigureAwait(false); - - return result; - } - - /// - /// Redeems a forgot password pin. - /// - /// The forgot password pin request containing the entered pin. - /// Pin reset process started. - /// A containing a . - [HttpPost("ForgotPassword/Pin")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> ForgotPasswordPin([FromBody, Required] ForgotPasswordPinDto forgotPasswordPinRequest) - { - var result = await _userManager.RedeemPasswordResetPin(forgotPasswordPinRequest.Pin).ConfigureAwait(false); - return result; - } - - /// - /// Gets the user based on auth token. - /// - /// User returned. - /// Token is not owned by a user. - /// A for the authenticated user. - [HttpGet("Me")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - public ActionResult GetCurrentUser() - { - var userId = User.GetUserId(); - if (userId.Equals(default)) - { - return BadRequest(); - } - - var user = _userManager.GetUserById(userId); - if (user is null) - { - return BadRequest(); - } - - return _userManager.GetUserDto(user); - } - - private IEnumerable Get(bool? isHidden, bool? isDisabled, bool filterByDevice, bool filterByNetwork) - { - var users = _userManager.Users; - - 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 (filterByDevice) - { - var deviceId = User.GetDeviceId(); - - if (!string.IsNullOrWhiteSpace(deviceId)) - { - users = users.Where(i => _deviceManager.CanAccessDevice(i, deviceId)); - } - } - - if (filterByNetwork) - { - if (!_networkManager.IsInLocalNetwork(HttpContext.GetNormalizedRemoteIp())) - { - users = users.Where(i => i.HasPermission(PermissionKind.EnableRemoteAccess)); - } - } - - var result = users - .OrderBy(u => u.Username) - .Select(i => _userManager.GetUserDto(i, HttpContext.GetNormalizedRemoteIp().ToString())); - - return result; + // rethrow adding IP address to message + throw new SecurityException($"[{HttpContext.GetNormalizedRemoteIp()}] {e.Message}", e); } } + + /// + /// Authenticates a user with quick connect. + /// + /// The request. + /// User authenticated. + /// Missing token. + /// A containing an with information about the new session. + [HttpPost("AuthenticateWithQuickConnect")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult AuthenticateWithQuickConnect([FromBody, Required] QuickConnectDto request) + { + try + { + return _quickConnectManager.GetAuthorizedRequest(request.Secret); + } + catch (SecurityException e) + { + // rethrow adding IP address to message + throw new SecurityException($"[{HttpContext.GetNormalizedRemoteIp()}] {e.Message}", e); + } + } + + /// + /// Updates a user's password. + /// + /// The user id. + /// The request. + /// Password successfully reset. + /// User is not allowed to update the password. + /// User not found. + /// A indicating success or a or a on failure. + [HttpPost("{userId}/Password")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task 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); + + if (user is null) + { + return NotFound("User not found"); + } + + if (request.ResetPassword) + { + await _userManager.ResetPassword(user).ConfigureAwait(false); + } + else + { + if (!User.IsInRole(UserRoles.Administrator) || User.GetUserId().Equals(userId)) + { + var success = await _userManager.AuthenticateUser( + user.Username, + request.CurrentPw ?? string.Empty, + request.CurrentPw ?? string.Empty, + HttpContext.GetNormalizedRemoteIp().ToString(), + false).ConfigureAwait(false); + + if (success is null) + { + return StatusCode(StatusCodes.Status403Forbidden, "Invalid user or password entered."); + } + } + + await _userManager.ChangePassword(user, request.NewPw ?? string.Empty).ConfigureAwait(false); + + var currentToken = User.GetToken(); + + await _sessionManager.RevokeUserTokens(user.Id, currentToken).ConfigureAwait(false); + } + + return NoContent(); + } + + /// + /// Updates a user's easy password. + /// + /// The user id. + /// The request. + /// Password successfully reset. + /// User is not allowed to update the password. + /// User not found. + /// A indicating success or a or a on failure. + [HttpPost("{userId}/EasyPassword")] + [Obsolete("Use Quick Connect instead")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult UpdateUserEasyPassword( + [FromRoute, Required] Guid userId, + [FromBody, Required] UpdateUserEasyPassword request) + { + return Forbid(); + } + + /// + /// Updates a user. + /// + /// The user id. + /// The updated user model. + /// User updated. + /// User information was not supplied. + /// User update forbidden. + /// A indicating success or a or a on failure. + [HttpPost("{userId}")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task UpdateUser( + [FromRoute, Required] Guid userId, + [FromBody, Required] UserDto updateUser) + { + var user = _userManager.GetUserById(userId); + if (user is null) + { + return NotFound(); + } + + if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true)) + { + return StatusCode(StatusCodes.Status403Forbidden, "User update not allowed."); + } + + if (!string.Equals(user.Username, updateUser.Name, StringComparison.Ordinal)) + { + await _userManager.RenameUser(user, updateUser.Name).ConfigureAwait(false); + } + + await _userManager.UpdateConfigurationAsync(user.Id, updateUser.Configuration).ConfigureAwait(false); + + return NoContent(); + } + + /// + /// Updates a user policy. + /// + /// The user id. + /// The new user policy. + /// User policy updated. + /// User policy was not supplied. + /// User policy update forbidden. + /// A indicating success or a or a on failure.. + [HttpPost("{userId}/Policy")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task UpdateUserPolicy( + [FromRoute, Required] Guid userId, + [FromBody, Required] UserPolicy newPolicy) + { + var user = _userManager.GetUserById(userId); + if (user is null) + { + return NotFound(); + } + + // 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."); + } + } + + // If disabling + if (newPolicy.IsDisabled && user.HasPermission(PermissionKind.IsAdministrator)) + { + return StatusCode(StatusCodes.Status403Forbidden, "Administrators cannot be disabled."); + } + + // 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."); + } + + var currentToken = User.GetToken(); + await _sessionManager.RevokeUserTokens(user.Id, currentToken).ConfigureAwait(false); + } + + await _userManager.UpdatePolicyAsync(userId, newPolicy).ConfigureAwait(false); + + return NoContent(); + } + + /// + /// Updates a user configuration. + /// + /// The user id. + /// The new user configuration. + /// User configuration updated. + /// User configuration update forbidden. + /// A indicating success. + [HttpPost("{userId}/Configuration")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task UpdateUserConfiguration( + [FromRoute, Required] Guid userId, + [FromBody, Required] UserConfiguration userConfig) + { + if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true)) + { + return StatusCode(StatusCodes.Status403Forbidden, "User configuration update not allowed"); + } + + await _userManager.UpdateConfigurationAsync(userId, userConfig).ConfigureAwait(false); + + return NoContent(); + } + + /// + /// Creates a user. + /// + /// The create user by name request body. + /// User created. + /// An of the new user. + [HttpPost("New")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> 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); + } + + var result = _userManager.GetUserDto(newUser, HttpContext.GetNormalizedRemoteIp().ToString()); + + return result; + } + + /// + /// Initiates the forgot password process for a local user. + /// + /// The forgot password request containing the entered username. + /// Password reset process started. + /// A containing a . + [HttpPost("ForgotPassword")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> ForgotPassword([FromBody, Required] ForgotPasswordDto forgotPasswordRequest) + { + var ip = HttpContext.GetNormalizedRemoteIp(); + var isLocal = HttpContext.IsLocal() + || _networkManager.IsInLocalNetwork(ip); + + if (isLocal) + { + _logger.LogWarning("Password reset process initiated from outside the local network with IP: {IP}", ip); + } + + var result = await _userManager.StartForgotPasswordProcess(forgotPasswordRequest.EnteredUsername, isLocal).ConfigureAwait(false); + + return result; + } + + /// + /// Redeems a forgot password pin. + /// + /// The forgot password pin request containing the entered pin. + /// Pin reset process started. + /// A containing a . + [HttpPost("ForgotPassword/Pin")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> ForgotPasswordPin([FromBody, Required] ForgotPasswordPinDto forgotPasswordPinRequest) + { + var result = await _userManager.RedeemPasswordResetPin(forgotPasswordPinRequest.Pin).ConfigureAwait(false); + return result; + } + + /// + /// Gets the user based on auth token. + /// + /// User returned. + /// Token is not owned by a user. + /// A for the authenticated user. + [HttpGet("Me")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public ActionResult GetCurrentUser() + { + var userId = User.GetUserId(); + if (userId.Equals(default)) + { + return BadRequest(); + } + + var user = _userManager.GetUserById(userId); + if (user is null) + { + return BadRequest(); + } + + return _userManager.GetUserDto(user); + } + + private IEnumerable Get(bool? isHidden, bool? isDisabled, bool filterByDevice, bool filterByNetwork) + { + var users = _userManager.Users; + + 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 (filterByDevice) + { + var deviceId = User.GetDeviceId(); + + if (!string.IsNullOrWhiteSpace(deviceId)) + { + users = users.Where(i => _deviceManager.CanAccessDevice(i, deviceId)); + } + } + + if (filterByNetwork) + { + if (!_networkManager.IsInLocalNetwork(HttpContext.GetNormalizedRemoteIp())) + { + users = users.Where(i => i.HasPermission(PermissionKind.EnableRemoteAccess)); + } + } + + var result = users + .OrderBy(u => u.Username) + .Select(i => _userManager.GetUserDto(i, HttpContext.GetNormalizedRemoteIp().ToString())); + + return result; + } } diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs index cd21c5f6ff..2c4fe91862 100644 --- a/Jellyfin.Api/Controllers/UserLibraryController.cs +++ b/Jellyfin.Api/Controllers/UserLibraryController.cs @@ -4,10 +4,9 @@ using System.ComponentModel.DataAnnotations; using System.Linq; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.ModelBinders; -using Jellyfin.Api.Models.UserDtos; +using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -23,406 +22,564 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// User library controller. +/// +[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; + /// - /// User library controller. + /// Initializes a new instance of the class. /// - [Route("")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class UserLibraryController : BaseJellyfinApiController + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + 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; + _userManager = userManager; + _userDataRepository = userDataRepository; + _libraryManager = libraryManager; + _dtoService = dtoService; + _userViewManager = userViewManager; + _fileSystem = fileSystem; + _lyricManager = lyricManager; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - public UserLibraryController( - IUserManager userManager, - IUserDataManager userDataRepository, - ILibraryManager libraryManager, - IDtoService dtoService, - IUserViewManager userViewManager, - IFileSystem fileSystem, - ILyricManager lyricManager) + /// + /// Gets an item from a user's library. + /// + /// User id. + /// Item id. + /// Item returned. + /// An containing the item. + [HttpGet("Users/{userId}/Items/{itemId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) + { + var user = _userManager.GetUserById(userId); + if (user is null) { - _userManager = userManager; - _userDataRepository = userDataRepository; - _libraryManager = libraryManager; - _dtoService = dtoService; - _userViewManager = userViewManager; - _fileSystem = fileSystem; - _lyricManager = lyricManager; - } - - /// - /// Gets an item from a user's library. - /// - /// User id. - /// Item id. - /// Item returned. - /// An containing the d item. - [HttpGet("Users/{userId}/Items/{itemId}")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> 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); - } - - /// - /// Gets the root folder from a user's library. - /// - /// User id. - /// Root folder returned. - /// An containing the user's root folder. - [HttpGet("Users/{userId}/Items/Root")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult 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); - } - - /// - /// Gets intros to play before the main media item plays. - /// - /// User id. - /// Item id. - /// Intros returned. - /// An containing the intros to play. - [HttpGet("Users/{userId}/Items/{itemId}/Intros")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task>> 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(dtos); - } - - /// - /// Marks an item as a favorite. - /// - /// User id. - /// Item id. - /// Item marked as favorite. - /// An containing the . - [HttpPost("Users/{userId}/FavoriteItems/{itemId}")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult MarkFavoriteItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) - { - return MarkFavorite(userId, itemId, true); - } - - /// - /// Unmarks item as a favorite. - /// - /// User id. - /// Item id. - /// Item unmarked as favorite. - /// An containing the . - [HttpDelete("Users/{userId}/FavoriteItems/{itemId}")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult UnmarkFavoriteItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) - { - return MarkFavorite(userId, itemId, false); - } - - /// - /// Deletes a user's saved personal rating for an item. - /// - /// User id. - /// Item id. - /// Personal rating removed. - /// An containing the . - [HttpDelete("Users/{userId}/Items/{itemId}/Rating")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult DeleteUserItemRating([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) - { - return UpdateUserItemRatingInternal(userId, itemId, null); - } - - /// - /// Updates a user's rating for an item. - /// - /// User id. - /// Item id. - /// Whether this is likes. - /// Item rating updated. - /// An containing the . - [HttpPost("Users/{userId}/Items/{itemId}/Rating")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult UpdateUserItemRating([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId, [FromQuery] bool? likes) - { - return UpdateUserItemRatingInternal(userId, itemId, likes); - } - - /// - /// Gets local trailers for an item. - /// - /// User id. - /// Item id. - /// An containing the item's local trailers. - /// The items local trailers. - [HttpGet("Users/{userId}/Items/{itemId}/LocalTrailers")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> 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()); - } - - return Ok(item.GetExtras() - .Where(e => e.ExtraType == ExtraType.Trailer) - .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item))); - } - - /// - /// Gets special features for an item. - /// - /// User id. - /// Item id. - /// Special features returned. - /// An containing the special features. - [HttpGet("Users/{userId}/Items/{itemId}/SpecialFeatures")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> 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))); - } - - /// - /// Gets latest media. - /// - /// User id. - /// Specify this to localize the search to a specific item or folder. Omit to use the root. - /// Optional. Specify additional fields of information to return in the output. - /// Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited. - /// Filter by items that are played, or not. - /// Optional. include image information in output. - /// Optional. the max number of images to return, per image type. - /// Optional. The image types to include in the output. - /// Optional. include user data. - /// Return item limit. - /// Whether or not to group items into a parent container. - /// Latest media returned. - /// An containing the latest media. - [HttpGet("Users/{userId}/Items/Latest")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> 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; - } - } - - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - - 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 (i.Item1 is not null && (i.Item2.Count > 1 || i.Item1 is MusicAlbum)) - { - item = i.Item1; - childCount = i.Item2.Count; - } - - 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) - { - var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) - { - MetadataRefreshMode = MetadataRefreshMode.FullRefresh, - ImageRefreshMode = MetadataRefreshMode.FullRefresh, - ForceSave = performFullRefresh - }; - - await item.RefreshMetadata(options, CancellationToken.None).ConfigureAwait(false); - } - } - } - - /// - /// Marks the favorite. - /// - /// The user id. - /// The item id. - /// if set to true [is favorite]. - private UserItemDataDto MarkFavorite(Guid userId, Guid itemId, bool isFavorite) - { - var user = _userManager.GetUserById(userId); - - var item = itemId.Equals(default) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId); - - // 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); - } - - /// - /// Updates the user item rating. - /// - /// The user id. - /// The item id. - /// if set to true [likes]. - private UserItemDataDto UpdateUserItemRatingInternal(Guid userId, Guid itemId, bool? likes) - { - var user = _userManager.GetUserById(userId); - - var item = itemId.Equals(default) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId); - - // 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); - } - - /// - /// Gets an item's lyrics. - /// - /// User id. - /// Item id. - /// Lyrics returned. - /// Something went wrong. No Lyrics will be returned. - /// An containing the item's lyrics. - [HttpGet("Users/{userId}/Items/{itemId}/Lyrics")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> 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(); - } - - var result = await _lyricManager.GetLyrics(item).ConfigureAwait(false); - if (result is not null) - { - return Ok(result); - } - 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}."); + } + + await RefreshItemOnDemandIfNeeded(item).ConfigureAwait(false); + + var dtoOptions = new DtoOptions().AddClientFields(User); + + return _dtoService.GetBaseItemDto(item, dtoOptions, user); + } + + /// + /// Gets the root folder from a user's library. + /// + /// User id. + /// Root folder returned. + /// An containing the user's root folder. + [HttpGet("Users/{userId}/Items/Root")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult 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); + } + + /// + /// Gets intros to play before the main media item plays. + /// + /// User id. + /// Item id. + /// Intros returned. + /// An containing the intros to play. + [HttpGet("Users/{userId}/Items/{itemId}/Intros")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task>> GetIntros([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 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(dtos); + } + + /// + /// Marks an item as a favorite. + /// + /// User id. + /// Item id. + /// Item marked as favorite. + /// An containing the . + [HttpPost("Users/{userId}/FavoriteItems/{itemId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult 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); + } + + /// + /// Unmarks item as a favorite. + /// + /// User id. + /// Item id. + /// Item unmarked as favorite. + /// An containing the . + [HttpDelete("Users/{userId}/FavoriteItems/{itemId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult 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); + } + + /// + /// Deletes a user's saved personal rating for an item. + /// + /// User id. + /// Item id. + /// Personal rating removed. + /// An containing the . + [HttpDelete("Users/{userId}/Items/{itemId}/Rating")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult 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}."); + } + + return UpdateUserItemRatingInternal(user, item, null); + } + + /// + /// Updates a user's rating for an item. + /// + /// User id. + /// Item id. + /// Whether this is likes. + /// Item rating updated. + /// An containing the . + [HttpPost("Users/{userId}/Items/{itemId}/Rating")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult 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)) + { + return Unauthorized($"{user.Username} is not permitted to access item {item.Name}."); + } + + return UpdateUserItemRatingInternal(user, item, likes); + } + + /// + /// Gets local trailers for an item. + /// + /// User id. + /// Item id. + /// An containing the item's local trailers. + /// The items local trailers. + [HttpGet("Users/{userId}/Items/{itemId}/LocalTrailers")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> GetLocalTrailers([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 dtoOptions = new DtoOptions().AddClientFields(User); + if (item is IHasTrailers hasTrailers) + { + var trailers = hasTrailers.LocalTrailers; + return Ok(_dtoService.GetBaseItemDtos(trailers, dtoOptions, user, item).AsEnumerable()); + } + + return Ok(item.GetExtras() + .Where(e => e.ExtraType == ExtraType.Trailer) + .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item))); + } + + /// + /// Gets special features for an item. + /// + /// User id. + /// Item id. + /// Special features returned. + /// An containing the special features. + [HttpGet("Users/{userId}/Items/{itemId}/SpecialFeatures")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> GetSpecialFeatures([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 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))); + } + + /// + /// Gets latest media. + /// + /// User id. + /// Specify this to localize the search to a specific item or folder. Omit to use the root. + /// Optional. Specify additional fields of information to return in the output. + /// Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited. + /// Filter by items that are played, or not. + /// Optional. include image information in output. + /// Optional. the max number of images to return, per image type. + /// Optional. The image types to include in the output. + /// Optional. include user data. + /// Return item limit. + /// Whether or not to group items into a parent container. + /// Latest media returned. + /// An containing the latest media. + [HttpGet("Users/{userId}/Items/Latest")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> 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) + { + return NotFound(); + } + + if (!isPlayed.HasValue) + { + if (user.HidePlayedInLatest) + { + isPlayed = false; + } + } + + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + + 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 (i.Item1 is not null && (i.Item2.Count > 1 || i.Item1 is MusicAlbum)) + { + item = i.Item1; + childCount = i.Item2.Count; + } + + 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) + { + var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ImageRefreshMode = MetadataRefreshMode.FullRefresh, + ForceSave = performFullRefresh + }; + + await item.RefreshMetadata(options, CancellationToken.None).ConfigureAwait(false); + } + } + } + + /// + /// Marks the favorite. + /// + /// The user. + /// The item. + /// if set to true [is favorite]. + 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); + } + + /// + /// Updates the user item rating. + /// + /// The user. + /// The item. + /// if set to true [likes]. + 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); + } + + /// + /// Gets an item's lyrics. + /// + /// User id. + /// Item id. + /// Lyrics returned. + /// Something went wrong. No Lyrics will be returned. + /// An containing the item's lyrics. + [HttpGet("Users/{userId}/Items/{itemId}/Lyrics")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetLyrics([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) + { + var user = _userManager.GetUserById(userId); + + if (user is null) + { + return NotFound(); + } + + var item = itemId.Equals(default) + ? _libraryManager.GetUserRootFolder() + : _libraryManager.GetItemById(itemId); + + if (item is null) + { + return NotFound(); + } + + if (item is not UserRootFolder + // Check the item is visible for the user + && !item.IsVisible(user)) + { + return Unauthorized($"{user.Username} is not permitted to access item {item.Name}."); + } + + var result = await _lyricManager.GetLyrics(item).ConfigureAwait(false); + if (result is not null) + { + return Ok(result); + } + + return NotFound(); } } diff --git a/Jellyfin.Api/Controllers/UserViewsController.cs b/Jellyfin.Api/Controllers/UserViewsController.cs index 3aeb444dfa..838b432340 100644 --- a/Jellyfin.Api/Controllers/UserViewsController.cs +++ b/Jellyfin.Api/Controllers/UserViewsController.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Globalization; using System.Linq; -using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.UserViewDtos; @@ -17,122 +16,121 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// User views controller. +/// +[Route("")] +[Authorize] +public class UserViewsController : BaseJellyfinApiController { + private readonly IUserManager _userManager; + private readonly IUserViewManager _userViewManager; + private readonly IDtoService _dtoService; + private readonly ILibraryManager _libraryManager; + /// - /// User views controller. + /// Initializes a new instance of the class. /// - [Route("")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class UserViewsController : BaseJellyfinApiController + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + 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; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - public UserViewsController( - IUserManager userManager, - IUserViewManager userViewManager, - IDtoService dtoService, - ILibraryManager libraryManager) + /// + /// Get user views. + /// + /// User id. + /// Whether or not to include external views such as channels or live tv. + /// Preset views. + /// Whether or not to include hidden content. + /// User views returned. + /// An containing the user views. + [HttpGet("Users/{userId}/Views")] + [ProducesResponseType(StatusCodes.Status200OK)] + public QueryResult 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 + }; + + if (includeExternalContent.HasValue) + { + query.IncludeExternalContent = includeExternalContent.Value; } - /// - /// Get user views. - /// - /// User id. - /// Whether or not to include external views such as channels or live tv. - /// Preset views. - /// Whether or not to include hidden content. - /// User views returned. - /// An containing the user views. - [HttpGet("Users/{userId}/Views")] - [ProducesResponseType(StatusCodes.Status200OK)] - public QueryResult GetUserViews( - [FromRoute, Required] Guid userId, - [FromQuery] bool? includeExternalContent, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] presetViews, - [FromQuery] bool includeHidden = false) + if (presetViews.Length != 0) { - var query = new UserViewQuery - { - UserId = userId, - IncludeHidden = includeHidden - }; - - if (includeExternalContent.HasValue) - { - query.IncludeExternalContent = includeExternalContent.Value; - } - - if (presetViews.Length != 0) - { - query.PresetViews = presetViews; - } - - var folders = _userViewManager.GetUserViews(query); - - 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(); - - var user = _userManager.GetUserById(userId); - - var dtos = folders.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user)) - .ToArray(); - - return new QueryResult(dtos); + query.PresetViews = presetViews; } - /// - /// Get user view grouping options. - /// - /// User id. - /// User view grouping options returned. - /// User not found. - /// - /// An containing the user view grouping options - /// or a if user not found. - /// - [HttpGet("Users/{userId}/GroupingOptions")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult> GetGroupingOptions([FromRoute, Required] Guid userId) - { - var user = _userManager.GetUserById(userId); - if (user is null) - { - return NotFound(); - } + var folders = _userViewManager.GetUserViews(query); - return Ok(_libraryManager.GetUserRootFolder() - .GetChildren(user, true) - .OfType() - .Where(UserView.IsEligibleForGrouping) - .Select(i => new SpecialViewOptionDto - { - Name = i.Name, - Id = i.Id.ToString("N", CultureInfo.InvariantCulture) - }) - .OrderBy(i => i.Name) - .AsEnumerable()); + 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(); + + var user = _userManager.GetUserById(userId); + + var dtos = folders.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user)) + .ToArray(); + + return new QueryResult(dtos); + } + + /// + /// Get user view grouping options. + /// + /// User id. + /// User view grouping options returned. + /// User not found. + /// + /// An containing the user view grouping options + /// or a if user not found. + /// + [HttpGet("Users/{userId}/GroupingOptions")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult> GetGroupingOptions([FromRoute, Required] Guid userId) + { + var user = _userManager.GetUserById(userId); + if (user is null) + { + return NotFound(); } + + return Ok(_libraryManager.GetUserRootFolder() + .GetChildren(user, true) + .OfType() + .Where(UserView.IsEligibleForGrouping) + .Select(i => new SpecialViewOptionDto + { + Name = i.Name, + Id = i.Id.ToString("N", CultureInfo.InvariantCulture) + }) + .OrderBy(i => i.Name) + .AsEnumerable()); } } diff --git a/Jellyfin.Api/Controllers/VideoAttachmentsController.cs b/Jellyfin.Api/Controllers/VideoAttachmentsController.cs index bb31626142..23b9ba46f6 100644 --- a/Jellyfin.Api/Controllers/VideoAttachmentsController.cs +++ b/Jellyfin.Api/Controllers/VideoAttachmentsController.cs @@ -10,73 +10,72 @@ using MediaBrowser.Controller.MediaEncoding; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// Attachments controller. +/// +[Route("Videos")] +public class VideoAttachmentsController : BaseJellyfinApiController { + private readonly ILibraryManager _libraryManager; + private readonly IAttachmentExtractor _attachmentExtractor; + /// - /// Attachments controller. + /// Initializes a new instance of the class. /// - [Route("Videos")] - public class VideoAttachmentsController : BaseJellyfinApiController + /// Instance of the interface. + /// Instance of the interface. + public VideoAttachmentsController( + ILibraryManager libraryManager, + IAttachmentExtractor attachmentExtractor) { - private readonly ILibraryManager _libraryManager; - private readonly IAttachmentExtractor _attachmentExtractor; + _libraryManager = libraryManager; + _attachmentExtractor = attachmentExtractor; + } - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - public VideoAttachmentsController( - ILibraryManager libraryManager, - IAttachmentExtractor attachmentExtractor) + /// + /// Get video attachment. + /// + /// Video ID. + /// Media Source ID. + /// Attachment Index. + /// Attachment retrieved. + /// Video or attachment not found. + /// An containing the attachment stream on success, or a if the attachment could not be found. + [HttpGet("{videoId}/{mediaSourceId}/Attachments/{index}")] + [ProducesFile(MediaTypeNames.Application.Octet)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetAttachment( + [FromRoute, Required] Guid videoId, + [FromRoute, Required] string mediaSourceId, + [FromRoute, Required] int index) + { + try { - _libraryManager = libraryManager; - _attachmentExtractor = attachmentExtractor; + var item = _libraryManager.GetItemById(videoId); + if (item is null) + { + return NotFound(); + } + + var (attachment, stream) = await _attachmentExtractor.GetAttachment( + item, + mediaSourceId, + index, + CancellationToken.None) + .ConfigureAwait(false); + + var contentType = string.IsNullOrWhiteSpace(attachment.MimeType) + ? MediaTypeNames.Application.Octet + : attachment.MimeType; + + return new FileStreamResult(stream, contentType); } - - /// - /// Get video attachment. - /// - /// Video ID. - /// Media Source ID. - /// Attachment Index. - /// Attachment retrieved. - /// Video or attachment not found. - /// An containing the attachment stream on success, or a if the attachment could not be found. - [HttpGet("{videoId}/{mediaSourceId}/Attachments/{index}")] - [ProducesFile(MediaTypeNames.Application.Octet)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task GetAttachment( - [FromRoute, Required] Guid videoId, - [FromRoute, Required] string mediaSourceId, - [FromRoute, Required] int index) + catch (ResourceNotFoundException e) { - try - { - var item = _libraryManager.GetItemById(videoId); - if (item is null) - { - return NotFound(); - } - - var (attachment, stream) = await _attachmentExtractor.GetAttachment( - item, - mediaSourceId, - index, - CancellationToken.None) - .ConfigureAwait(false); - - var contentType = string.IsNullOrWhiteSpace(attachment.MimeType) - ? MediaTypeNames.Application.Octet - : attachment.MimeType; - - return new FileStreamResult(stream, contentType); - } - catch (ResourceNotFoundException e) - { - return NotFound(e.Message); - } + return NotFound(e.Message); } } } diff --git a/Jellyfin.Api/Controllers/VideosController.cs b/Jellyfin.Api/Controllers/VideosController.cs index 64d8fb498b..c0ec646eda 100644 --- a/Jellyfin.Api/Controllers/VideosController.cs +++ b/Jellyfin.Api/Controllers/VideosController.cs @@ -21,7 +21,6 @@ using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Controller.Net; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; @@ -32,644 +31,649 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// +/// The videos controller. +/// +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; + /// - /// The videos controller. + /// Initializes a new instance of the class. /// - public class VideosController : BaseJellyfinApiController + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the class. + /// Instance of the interface. + /// Instance of . + public VideosController( + ILibraryManager libraryManager, + IUserManager userManager, + IDtoService dtoService, + IDlnaManager dlnaManager, + IMediaSourceManager mediaSourceManager, + IServerConfigurationManager serverConfigurationManager, + IMediaEncoder mediaEncoder, + IDeviceManager deviceManager, + TranscodingJobHelper transcodingJobHelper, + IHttpClientFactory httpClientFactory, + EncodingHelper encodingHelper) { - 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; + _libraryManager = libraryManager; + _userManager = userManager; + _dtoService = dtoService; + _dlnaManager = dlnaManager; + _mediaSourceManager = mediaSourceManager; + _serverConfigurationManager = serverConfigurationManager; + _mediaEncoder = mediaEncoder; + _deviceManager = deviceManager; + _transcodingJobHelper = transcodingJobHelper; + _httpClientFactory = httpClientFactory; + _encodingHelper = encodingHelper; + } - private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Progressive; + /// + /// Gets additional parts for a video. + /// + /// The item id. + /// Optional. Filter by user id, and attach user data. + /// Additional parts returned. + /// A with the parts. + [HttpGet("{itemId}/AdditionalParts")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> GetAdditionalPart([FromRoute, Required] Guid itemId, [FromQuery] Guid? userId) + { + userId = RequestHelpers.GetUserId(User, userId); + var user = userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); - /// - /// Initializes a new instance of the class. - /// - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. - /// Instance of the class. - /// Instance of the interface. - /// Instance of . - public VideosController( - ILibraryManager libraryManager, - IUserManager userManager, - IDtoService dtoService, - IDlnaManager dlnaManager, - IMediaSourceManager mediaSourceManager, - IServerConfigurationManager serverConfigurationManager, - IMediaEncoder mediaEncoder, - IDeviceManager deviceManager, - TranscodingJobHelper transcodingJobHelper, - IHttpClientFactory httpClientFactory, - EncodingHelper encodingHelper) + 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(); + } + else + { + items = Array.Empty(); } - /// - /// Gets additional parts for a video. - /// - /// The item id. - /// Optional. Filter by user id, and attach user data. - /// Additional parts returned. - /// A with the parts. - [HttpGet("{itemId}/AdditionalParts")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> GetAdditionalPart([FromRoute, Required] Guid itemId, [FromQuery] Guid? userId) + var result = new QueryResult(items); + return result; + } + + /// + /// Removes alternate video sources. + /// + /// The item id. + /// Alternate sources deleted. + /// Video not found. + /// A indicating success, or a if the video doesn't exist. + [HttpDelete("{itemId}/AlternateSources")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeleteAlternateSources([FromRoute, Required] Guid itemId) + { + var video = (Video)_libraryManager.GetItemById(itemId); + + if (video is null) { - 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); - - var dtoOptions = new DtoOptions(); - dtoOptions = dtoOptions.AddClientFields(User); - - BaseItemDto[] items; - if (item is Video video) - { - items = video.GetAdditionalParts() - .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, video)) - .ToArray(); - } - else - { - items = Array.Empty(); - } - - var result = new QueryResult(items); - return result; + return NotFound("The video either does not exist or the id does not belong to a video."); } - /// - /// Removes alternate video sources. - /// - /// The item id. - /// Alternate sources deleted. - /// Video not found. - /// A indicating success, or a if the video doesn't exist. - [HttpDelete("{itemId}/AlternateSources")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task DeleteAlternateSources([FromRoute, Required] Guid itemId) + if (video.LinkedAlternateVersions.Length == 0) { - var video = (Video)_libraryManager.GetItemById(itemId); - - if (video is null) - { - return NotFound("The video either does not exist or the id does not belong to a video."); - } - - if (video.LinkedAlternateVersions.Length == 0) - { - video = (Video)_libraryManager.GetItemById(video.PrimaryVersionId); - } - - foreach (var link in video.GetLinkedAlternateVersions()) - { - link.SetPrimaryVersionId(null); - link.LinkedAlternateVersions = Array.Empty(); - - await link.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); - } - - video.LinkedAlternateVersions = Array.Empty(); - video.SetPrimaryVersionId(null); - await video.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); - - return NoContent(); + video = (Video?)_libraryManager.GetItemById(video.PrimaryVersionId); } - /// - /// Merges videos into a single record. - /// - /// Item id list. This allows multiple, comma delimited. - /// Videos merged. - /// Supply at least 2 video ids. - /// A indicating success, or a if less than two ids were supplied. - [HttpPost("MergeVersions")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - public async Task MergeVersions([FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) + if (video is null) { - var items = ids - .Select(i => _libraryManager.GetItemById(i)) - .OfType